diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 5937496..08bf6d9 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -21,7 +21,7 @@ jobs: python-version: "3.14" - name: Install dependencies - run: uv sync + run: uv sync --group observ # On PRs: run benchmarks twice (PR code vs master code) and compare - name: Run benchmarks diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2cad53..a1a5e94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: with: python-version: ${{ matrix.pyversion }} - name: Install dependencies - run: uv sync + run: uv sync --group observ - name: Lint run: uv run ruff check - name: Format @@ -61,7 +61,7 @@ jobs: with: python-version: '3.9' - name: Install dependencies - run: uv sync + run: uv sync --group observ - name: Build wheel run: uv build - name: Twine check diff --git a/README.md b/README.md index 567475b..5ae9b84 100644 --- a/README.md +++ b/README.md @@ -43,3 +43,54 @@ print(to_json(ops, indent=4)) # } # ] ``` + +## Proxy-based patch generation + +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)): + +```python +from patchdiff import produce + +base = {"count": 0, "items": [1, 2, 3]} + +def recipe(draft): + """Mutate the draft object - changes are tracked automatically.""" + draft["count"] = 5 + draft["items"].append(4) + draft["new_field"] = "hello" + +result, patches, reverse_patches = produce(base, recipe) + +# base is unchanged (immutable by default) +assert base == {"count": 0, "items": [1, 2, 3]} + +# result contains the changes +assert result == {"count": 5, "items": [1, 2, 3, 4], "new_field": "hello"} + +# patches describe what changed +print(patches) +# [ +# {"op": "replace", "path": "/count", "value": 5}, +# {"op": "add", "path": "/items/-", "value": 4}, +# {"op": "add", "path": "/new_field", "value": "hello"} +# ] +``` + +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. + +```python +from observ import reactive +from patchdiff import produce + +state = reactive({"count": 0}) + +# Mutate in place and get patches for undo/redo +result, patches, reverse = produce( + state, + lambda draft: draft.update({"count": 5}), + in_place=True, +) + +assert result is state # Same object +assert state["count"] == 5 # State was mutated, watchers triggered +``` diff --git a/benchmarks/benchmark.py b/benchmarks/benchmark.py index 700c031..063abf2 100644 --- a/benchmarks/benchmark.py +++ b/benchmarks/benchmark.py @@ -14,13 +14,22 @@ uv run pytest benchmarks/benchmark.py --benchmark-only --benchmark-compare=0001 --benchmark-compare-fail=mean:5% """ +import copy import random import pytest -from patchdiff import apply, diff +from patchdiff import apply, diff, produce from patchdiff.pointer import Pointer +# Optional observ integration for benchmarks +try: + from observ import reactive, to_raw + + OBSERV_AVAILABLE = True +except ImportError: + OBSERV_AVAILABLE = False + # Set seed for reproducibility random.seed(42) @@ -230,3 +239,473 @@ def test_pointer_append(benchmark): ptr = Pointer.from_str("/a/b/c/d/e/f/g/h/i/j") benchmark(ptr.append, "k") + + +# ======================================== +# Produce vs Diff Comparison Benchmarks +# ======================================== + + +# --- Dict Benchmarks --- + +DICT_SMALL_BASE = {f"key_{i}": i for i in range(100)} +DICT_LARGE_BASE = {f"key_{i}": i for i in range(1000)} + + +def dict_small_mutations_recipe(draft): + """Recipe for small dict mutations.""" + draft["key_10"] = 999 + draft["new_key"] = "new_value" + del draft["key_50"] + + +def dict_many_mutations_recipe(draft): + """Recipe for many dict mutations.""" + # Modify 20% of keys + for i in range(200): + draft[f"key_{i}"] = i + 10000 + # Add 10% new keys + for i in range(100): + draft[f"new_key_{i}"] = i + # Remove 10% of keys + for i in range(100): + del draft[f"key_{i + 200}"] + + +@pytest.mark.benchmark(group="produce-vs-diff-dict") +def test_diff_dict_small_mutations(benchmark): + """Benchmark: diff() on dict with small mutations (baseline).""" + + def run(): + result = copy.deepcopy(DICT_SMALL_BASE) + dict_small_mutations_recipe(result) + return diff(DICT_SMALL_BASE, result) + + benchmark(run) + + +@pytest.mark.parametrize("in_place", [False, True], ids=["copy", "in_place"]) +@pytest.mark.benchmark(group="produce-vs-diff-dict") +def test_produce_dict_small_mutations(benchmark, in_place): + """Benchmark: produce() on dict with small mutations.""" + + def run(): + data = copy.deepcopy(DICT_SMALL_BASE) + return produce(data, dict_small_mutations_recipe, in_place=in_place) + + benchmark(run) + + +@pytest.mark.benchmark(group="produce-vs-diff-dict") +def test_diff_dict_many_mutations(benchmark): + """Benchmark: diff() on dict with many mutations (baseline).""" + + def run(): + result = copy.deepcopy(DICT_LARGE_BASE) + dict_many_mutations_recipe(result) + return diff(DICT_LARGE_BASE, result) + + benchmark(run) + + +@pytest.mark.parametrize("in_place", [False, True], ids=["copy", "in_place"]) +@pytest.mark.benchmark(group="produce-vs-diff-dict") +def test_produce_dict_many_mutations(benchmark, in_place): + """Benchmark: produce() on dict with many mutations.""" + + def run(): + data = copy.deepcopy(DICT_LARGE_BASE) + return produce(data, dict_many_mutations_recipe, in_place=in_place) + + benchmark(run) + + +# --- List Benchmarks --- + +LIST_BASE = list(range(100)) + + +def list_small_mutations_recipe(draft): + """Recipe for small list mutations.""" + draft.append(999) + draft.insert(10, 888) + draft[50] = 777 + del draft[20] + + +def list_many_appends_recipe(draft): + """Recipe for many list appends.""" + for i in range(100): + draft.append(i + 1000) + + +@pytest.mark.benchmark(group="produce-vs-diff-list") +def test_diff_list_small_mutations(benchmark): + """Benchmark: diff() on list with small mutations (baseline).""" + + def run(): + result = copy.deepcopy(LIST_BASE) + list_small_mutations_recipe(result) + return diff(LIST_BASE, result) + + benchmark(run) + + +@pytest.mark.parametrize("in_place", [False, True], ids=["copy", "in_place"]) +@pytest.mark.benchmark(group="produce-vs-diff-list") +def test_produce_list_small_mutations(benchmark, in_place): + """Benchmark: produce() on list with small mutations.""" + + def run(): + data = copy.deepcopy(LIST_BASE) + return produce(data, list_small_mutations_recipe, in_place=in_place) + + benchmark(run) + + +@pytest.mark.benchmark(group="produce-vs-diff-list") +def test_diff_list_many_appends(benchmark): + """Benchmark: diff() on list with many appends (baseline).""" + + def run(): + result = copy.deepcopy(LIST_BASE) + list_many_appends_recipe(result) + return diff(LIST_BASE, result) + + benchmark(run) + + +@pytest.mark.parametrize("in_place", [False, True], ids=["copy", "in_place"]) +@pytest.mark.benchmark(group="produce-vs-diff-list") +def test_produce_list_many_appends(benchmark, in_place): + """Benchmark: produce() on list with many appends.""" + + def run(): + data = copy.deepcopy(LIST_BASE) + return produce(data, list_many_appends_recipe, in_place=in_place) + + benchmark(run) + + +# --- Nested Structure Benchmarks --- + +NESTED_BASE = { + "users": [ + {"name": f"User{i}", "age": 20 + i, "tags": set(range(i, i + 5))} + for i in range(50) + ] +} + + +def nested_structure_recipe(draft): + """Recipe for nested structure mutations.""" + draft["users"][10]["age"] = 99 + draft["users"][10]["tags"].add(999) + draft["users"].append({"name": "NewUser", "age": 25, "tags": {1, 2, 3}}) + draft["admin"] = True + + +@pytest.mark.benchmark(group="produce-vs-diff-nested") +def test_diff_nested_structure(benchmark): + """Benchmark: diff() on nested dict/list structure (baseline).""" + + def run(): + result = copy.deepcopy(NESTED_BASE) + nested_structure_recipe(result) + return diff(NESTED_BASE, result) + + benchmark(run) + + +@pytest.mark.parametrize("in_place", [False, True], ids=["copy", "in_place"]) +@pytest.mark.benchmark(group="produce-vs-diff-nested") +def test_produce_nested_structure(benchmark, in_place): + """Benchmark: produce() on nested dict/list structure.""" + + def run(): + data = copy.deepcopy(NESTED_BASE) + return produce(data, nested_structure_recipe, in_place=in_place) + + benchmark(run) + + +# --- Set Benchmarks --- + +SET_BASE = set(range(500)) + + +def set_mutations_recipe(draft): + """Recipe for set mutations.""" + for i in range(50): + draft.add(i + 1000) + for i in range(50): + draft.discard(i) + + +@pytest.mark.benchmark(group="produce-vs-diff-set") +def test_diff_set_mutations(benchmark): + """Benchmark: diff() on set with mutations (baseline).""" + + def run(): + result = copy.deepcopy(SET_BASE) + set_mutations_recipe(result) + return diff(SET_BASE, result) + + benchmark(run) + + +@pytest.mark.parametrize("in_place", [False, True], ids=["copy", "in_place"]) +@pytest.mark.benchmark(group="produce-vs-diff-set") +def test_produce_set_mutations(benchmark, in_place): + """Benchmark: produce() on set with mutations.""" + + def run(): + data = copy.deepcopy(SET_BASE) + return produce(data, set_mutations_recipe, in_place=in_place) + + benchmark(run) + + +# --- Deep Nested Benchmarks --- + +DEEP_NESTED_BASE = { + "level1": {"level2": {"level3": {"level4": {"data": list(range(100))}}}} +} + + +def deep_nested_recipe(draft): + """Recipe for deep nested mutation.""" + draft["level1"]["level2"]["level3"]["level4"]["data"].append(999) + + +@pytest.mark.benchmark(group="produce-vs-diff-deep") +def test_diff_deep_nested_mutation(benchmark): + """Benchmark: diff() with deep nested mutation (baseline).""" + + def run(): + result = copy.deepcopy(DEEP_NESTED_BASE) + deep_nested_recipe(result) + return diff(DEEP_NESTED_BASE, result) + + benchmark(run) + + +@pytest.mark.parametrize("in_place", [False, True], ids=["copy", "in_place"]) +@pytest.mark.benchmark(group="produce-vs-diff-deep") +def test_produce_deep_nested_mutation(benchmark, in_place): + """Benchmark: produce() with deep nested mutation.""" + + def run(): + data = copy.deepcopy(DEEP_NESTED_BASE) + return produce(data, deep_nested_recipe, in_place=in_place) + + benchmark(run) + + +# --- Sparse Mutations Benchmarks --- + +SPARSE_BASE = {f"key_{i}": list(range(100)) for i in range(100)} + + +def sparse_mutations_recipe(draft): + """Recipe for sparse mutations on large object.""" + # Only mutate 3 keys out of 100 + draft["key_10"][0] = 999 + draft["key_50"][50] = 888 + draft["key_90"][90] = 777 + + +@pytest.mark.benchmark(group="produce-vs-diff-sparse") +def test_diff_sparse_mutations_large_object(benchmark): + """Benchmark: diff() with sparse mutations on large object (baseline).""" + + def run(): + result = copy.deepcopy(SPARSE_BASE) + sparse_mutations_recipe(result) + return diff(SPARSE_BASE, result) + + benchmark(run) + + +@pytest.mark.parametrize("in_place", [False, True], ids=["copy", "in_place"]) +@pytest.mark.benchmark(group="produce-vs-diff-sparse") +def test_produce_sparse_mutations_large_object(benchmark, in_place): + """Benchmark: produce() with sparse mutations on large object.""" + + def run(): + data = copy.deepcopy(SPARSE_BASE) + return produce(data, sparse_mutations_recipe, in_place=in_place) + + benchmark(run) + + +# ======================================== +# Observ + produce(in_place=True) Benchmarks +# ======================================== + + +@pytest.mark.skipif(not OBSERV_AVAILABLE, reason="observ not installed") +@pytest.mark.benchmark(group="observ-diff") +def test_diff_observ_dict_mutations(benchmark): + """Benchmark: diff() on observ reactive dict (baseline).""" + base_data = {f"key_{i}": i for i in range(100)} + + def run(): + state = reactive(base_data.copy()) + result = reactive(base_data.copy()) + result["key_10"] = 999 + result["new_key"] = "new_value" + del result["key_50"] + return diff(to_raw(state), to_raw(result)) + + benchmark(run) + + +@pytest.mark.skipif(not OBSERV_AVAILABLE, reason="observ not installed") +@pytest.mark.benchmark(group="observ-produce") +def test_produce_observ_dict_mutations_copy(benchmark): + """Benchmark: produce() on observ reactive dict (copy mode).""" + base_data = {f"key_{i}": i for i in range(100)} + + def run(): + state = reactive(base_data.copy()) + + def recipe(draft): + draft["key_10"] = 999 + draft["new_key"] = "new_value" + del draft["key_50"] + + return produce(state, recipe, in_place=False) + + benchmark(run) + + +@pytest.mark.skipif(not OBSERV_AVAILABLE, reason="observ not installed") +@pytest.mark.benchmark(group="observ-produce") +def test_produce_observ_dict_mutations_in_place(benchmark): + """Benchmark: produce() on observ reactive dict (in_place=True).""" + base_data = {f"key_{i}": i for i in range(100)} + + def run(): + state = reactive(base_data.copy()) + + def recipe(draft): + draft["key_10"] = 999 + draft["new_key"] = "new_value" + del draft["key_50"] + + return produce(state, recipe, in_place=True) + + benchmark(run) + + +@pytest.mark.skipif(not OBSERV_AVAILABLE, reason="observ not installed") +@pytest.mark.benchmark(group="observ-nested") +def test_diff_observ_nested_structure(benchmark): + """Benchmark: diff() on nested observ reactive structure (baseline).""" + base_data = { + "users": [{"name": f"User{i}", "age": 20 + i} for i in range(50)], + "settings": {"theme": "light"}, + } + + def run(): + state = reactive(base_data.copy()) + result = reactive(base_data.copy()) + result["users"][10]["age"] = 99 + result["settings"]["theme"] = "dark" + result["admin"] = True + return diff(to_raw(state), to_raw(result)) + + benchmark(run) + + +@pytest.mark.skipif(not OBSERV_AVAILABLE, reason="observ not installed") +@pytest.mark.benchmark(group="observ-nested") +def test_produce_observ_nested_in_place(benchmark): + """Benchmark: produce(in_place=True) on nested observ reactive structure.""" + base_data = { + "users": [{"name": f"User{i}", "age": 20 + i} for i in range(50)], + "settings": {"theme": "light"}, + } + + def run(): + state = reactive(base_data.copy()) + + def recipe(draft): + draft["users"][10]["age"] = 99 + draft["settings"]["theme"] = "dark" + draft["admin"] = True + + return produce(state, recipe, in_place=True) + + benchmark(run) + + +@pytest.mark.skipif(not OBSERV_AVAILABLE, reason="observ not installed") +@pytest.mark.benchmark(group="observ-list") +def test_produce_observ_list_many_appends_copy(benchmark): + """Benchmark: produce() on observ reactive list (copy mode).""" + + def run(): + state = reactive(list(range(100))) + + def recipe(draft): + for i in range(100): + draft.append(i + 1000) + + return produce(state, recipe, in_place=False) + + benchmark(run) + + +@pytest.mark.skipif(not OBSERV_AVAILABLE, reason="observ not installed") +@pytest.mark.benchmark(group="observ-list") +def test_produce_observ_list_many_appends_in_place(benchmark): + """Benchmark: produce(in_place=True) on observ reactive list.""" + + def run(): + state = reactive(list(range(100))) + + def recipe(draft): + for i in range(100): + draft.append(i + 1000) + + return produce(state, recipe, in_place=True) + + benchmark(run) + + +@pytest.mark.skipif(not OBSERV_AVAILABLE, reason="observ not installed") +@pytest.mark.benchmark(group="observ-performance") +def test_produce_in_place_vs_copy_dict(benchmark): + """Benchmark: Compare in_place=True vs in_place=False for dict.""" + base_data = {f"key_{i}": i for i in range(1000)} + + def run(): + state = reactive(base_data.copy()) + + def recipe(draft): + for i in range(100): + draft[f"key_{i}"] = i + 10000 + + return produce(state, recipe, in_place=True) + + benchmark(run) + + +@pytest.mark.skipif(not OBSERV_AVAILABLE, reason="observ not installed") +@pytest.mark.benchmark(group="observ-performance") +def test_produce_copy_mode_dict(benchmark): + """Benchmark: produce() with in_place=False for comparison.""" + base_data = {f"key_{i}": i for i in range(1000)} + + def run(): + state = reactive(base_data.copy()) + + def recipe(draft): + for i in range(100): + draft[f"key_{i}"] = i + 10000 + + return produce(state, recipe, in_place=False) + + benchmark(run) diff --git a/patchdiff/__init__.py b/patchdiff/__init__.py index f2065e0..0697109 100644 --- a/patchdiff/__init__.py +++ b/patchdiff/__init__.py @@ -4,4 +4,5 @@ from .apply import apply, iapply from .diff import diff +from .produce import produce from .serialize import to_json diff --git a/patchdiff/pointer.py b/patchdiff/pointer.py index 9948525..cf4dbb1 100644 --- a/patchdiff/pointer.py +++ b/patchdiff/pointer.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import Any, Hashable, Iterable, Tuple +from typing import Any, Hashable, Iterable from .types import Diffable @@ -41,12 +41,12 @@ def __repr__(self) -> str: def __hash__(self) -> int: return hash(self.tokens) - def __eq__(self, other: "Pointer") -> bool: + def __eq__(self, other: Any) -> bool: if other.__class__ != self.__class__: return False return self.tokens == other.tokens - def evaluate(self, obj: Diffable) -> Tuple[Diffable, Hashable, Any]: + def evaluate(self, obj: Diffable) -> tuple[Diffable, Hashable, Any]: key = "" parent = None cursor = obj diff --git a/patchdiff/produce.py b/patchdiff/produce.py new file mode 100644 index 0000000..f4cb365 --- /dev/null +++ b/patchdiff/produce.py @@ -0,0 +1,660 @@ +"""Proxy-based patch generation for tracking mutations in real-time. + +This module provides an alternative to diffing by using proxy objects that +monitor mutations as they are being made and emit patches immediately. +This approach is inspired by Immer's proxy-based implementation. +""" + +from __future__ import annotations + +import copy +from typing import Any, Callable, Dict, List, Set, Tuple, Union + +from .pointer import Pointer + +# Optional observ integration +try: + from observ import to_raw as observ_to_raw +except ImportError: # pragma: no cover + observ_to_raw = None + + +def _add_reader_methods(proxy_class, method_names): + """Add simple pass-through reader methods to a proxy class. + + These methods don't modify the object and just pass through to _data. + """ + + def _make_reader(name): + def reader(self, *args, **kwargs): + method = getattr(self._data, name) + return method(*args, **kwargs) + + reader.__name__ = name + reader.__qualname__ = f"{proxy_class.__name__}.{name}" + return reader + + for method_name in method_names: + setattr(proxy_class, method_name, _make_reader(method_name)) + + +class PatchRecorder: + """Records patches as mutations happen on proxy objects.""" + + def __init__(self): + self.patches: List[Dict] = [] + self.reverse_patches: List[Dict] = [] + + def record_add( + self, path: Pointer, value: Any, reverse_path: Pointer = None + ) -> None: + """Record an add operation. + + Args: + path: The path for the add operation + value: The value being added + reverse_path: Optional path for the reverse (remove) operation. + If not provided, uses the same path. This is needed + for sets where add uses "/-" but remove needs "/value". + """ + self.patches.append({"op": "add", "path": path, "value": value}) + self.reverse_patches.insert( + 0, {"op": "remove", "path": reverse_path if reverse_path else path} + ) + + def record_remove( + self, path: Pointer, old_value: Any, reverse_path: Pointer | None = None + ) -> None: + """Record a remove operation. + + Args: + path: The path where the item is being removed + old_value: The value being removed + reverse_path: Optional path for the reverse (add) operation. + If not provided, uses the same path. This is needed + for lists where remove uses "/index" but add needs "/-" + when removing from the end. + """ + self.patches.append({"op": "remove", "path": path}) + self.reverse_patches.insert( + 0, + { + "op": "add", + "path": reverse_path if reverse_path else path, + "value": old_value, + }, + ) + + def record_replace(self, path: Pointer, old_value: Any, new_value: Any) -> None: + """Record a replace operation, but only if the value actually changed.""" + if old_value == new_value: + return # Skip no-op replacements + self.patches.append({"op": "replace", "path": path, "value": new_value}) + self.reverse_patches.insert( + 0, {"op": "replace", "path": path, "value": old_value} + ) + + +class DictProxy: + """Proxy for dict objects that tracks mutations and generates patches.""" + + def __init__(self, data: Dict, recorder: PatchRecorder, path: Pointer): + self._data = data + self._recorder = recorder + self._path = path + self._proxies = {} + + def _wrap(self, key: Any, value: Any) -> Any: + """Wrap nested structures in proxies using duck typing.""" + # Check cache first - it's faster than hasattr() calls + if key in self._proxies: + return self._proxies[key] + + # Use duck typing to support observ reactive objects and other proxies + if hasattr(value, "keys"): # dict-like + proxy = DictProxy(value, self._recorder, self._path.append(key)) + self._proxies[key] = proxy + return proxy + elif hasattr(value, "append"): # list-like + proxy = ListProxy(value, self._recorder, self._path.append(key)) + self._proxies[key] = proxy + return proxy + elif hasattr(value, "add") and hasattr(value, "discard"): # set-like + proxy = SetProxy(value, self._recorder, self._path.append(key)) + self._proxies[key] = proxy + return proxy + return value + + def __getitem__(self, key: Any) -> Any: + value = self._data[key] + return self._wrap(key, value) + + def __setitem__(self, key: Any, value: Any) -> None: + path = self._path.append(key) + if key in self._data: + old_value = self._data[key] + self._recorder.record_replace(path, old_value, value) + else: + self._recorder.record_add(path, value) + self._data[key] = value + # Invalidate proxy cache for this key + if key in self._proxies: + del self._proxies[key] + + def __delitem__(self, key: Any) -> None: + old_value = self._data[key] + path = self._path.append(key) + self._recorder.record_remove(path, old_value) + del self._data[key] + # Invalidate proxy cache for this key + if key in self._proxies: + del self._proxies[key] + + def get(self, key: Any, default=None): + if key in self._data: + return self[key] + return default + + def pop(self, key: Any, default=None): + if key in self._data: + old_value = self._data[key] + path = self._path.append(key) + self._recorder.record_remove(path, old_value) + result = self._data.pop(key) + # Invalidate proxy cache for this key + if key in self._proxies: + del self._proxies[key] + return result + elif default: + return default + else: + raise KeyError(key) + + def setdefault(self, key: Any, default=None): + if key not in self._data: + self[key] = default + return default + return self[key] + + def update(self, *args, **kwargs): + # Collect all key-value pairs to update + items = [] + if args: + other = args[0] + if hasattr(other, "items"): + items.extend(other.items()) + else: + items.extend(other) + items.extend(kwargs.items()) + + # Generate patches and update data + for key, value in items: + path = self._path.append(key) + if key in self._data: + old_value = self._data[key] + self._recorder.record_replace(path, old_value, value) + else: + self._recorder.record_add(path, value) + self._data[key] = value + # Invalidate proxy cache for this key + if key in self._proxies: + del self._proxies[key] + + def clear(self): + # Generate patches for all keys and clear data + for key, value in list(self._data.items()): + path = self._path.append(key) + self._recorder.record_remove(path, value) + self._data.clear() + self._proxies.clear() + + def popitem(self): + key, value = self._data.popitem() + path = self._path.append(key) + self._recorder.record_remove(path, value) + # Invalidate proxy cache for this key + if key in self._proxies: + del self._proxies[key] + return key, value + + +# Add simple reader methods to DictProxy +_add_reader_methods( + DictProxy, + [ + "__len__", + "__contains__", + "__repr__", + "__iter__", + "__reversed__", + "keys", + "values", + "items", + "copy", + ], +) + + +class ListProxy: + """Proxy for list objects that tracks mutations and generates patches.""" + + def __init__(self, data: List, recorder: PatchRecorder, path: Pointer): + self._data = data + self._recorder = recorder + self._path = path + self._proxies = {} + + def _wrap(self, index: int, value: Any) -> Any: + """Wrap nested structures in proxies using duck typing.""" + # Check cache first - it's faster than hasattr() calls + if index in self._proxies: + return self._proxies[index] + + # Use duck typing to support observ reactive objects and other proxies + if hasattr(value, "keys"): # dict-like + proxy = DictProxy(value, self._recorder, self._path.append(index)) + self._proxies[index] = proxy + return proxy + elif hasattr(value, "append"): # list-like + proxy = ListProxy(value, self._recorder, self._path.append(index)) + self._proxies[index] = proxy + return proxy + elif hasattr(value, "add") and hasattr(value, "discard"): # set-like + proxy = SetProxy(value, self._recorder, self._path.append(index)) + self._proxies[index] = proxy + return proxy + return value + + def __getitem__(self, index: Union[int, slice]) -> Any: + value = self._data[index] + if isinstance(index, slice): + # Wrap each element in the slice so nested mutations are tracked + start, stop, step = index.indices(len(self._data)) + indices = range(start, stop, step) + return [self._wrap(i, self._data[i]) for i in indices] + # Resolve negative indices to positive for consistent caching and paths + if index < 0: + index = len(self._data) + index + return self._wrap(index, value) + + def __setitem__(self, index: Union[int, slice], value: Any) -> None: + if isinstance(index, slice): + # Handle slice assignment with proper patch generation + start, stop, step = index.indices(len(self._data)) + + if step != 1: + # Step slices must have same length + old_values = self._data[index] + if len(old_values) != len(value): + raise ValueError( + f"attempt to assign sequence of size {len(value)} " + f"to extended slice of size {len(old_values)}" + ) + # Replace each element in the stepped slice + for i, (idx, new_val) in enumerate( + zip(range(start, stop, step), value) + ): + path = self._path.append(idx) + old_val = self._data[idx] + self._recorder.record_replace(path, old_val, new_val) + self._data[idx] = new_val + else: + # Contiguous slice - can change length + old_values = list(self._data[start:stop]) + new_values = list(value) + + # Perform the slice assignment + self._data[start:stop] = new_values + + # Generate patches for the changes + old_len = len(old_values) + new_len = len(new_values) + + # Replace common elements + for i in range(min(old_len, new_len)): + if old_values[i] != new_values[i]: + path = self._path.append(start + i) + self._recorder.record_replace( + path, old_values[i], new_values[i] + ) + + # Add new elements if new slice is longer + if new_len > old_len: + for i in range(old_len, new_len): + path = self._path.append(start + i) + self._recorder.record_add(path, new_values[i]) + + # Remove extra elements if new slice is shorter + elif new_len < old_len: + # Remove from end to start to maintain correct indices + for i in range(old_len - 1, new_len - 1, -1): + path = self._path.append(start + i) + self._recorder.record_remove(path, old_values[i]) + + # Invalidate all proxy caches as indices may have shifted + self._proxies.clear() + return + + # Resolve negative indices to positive for correct paths + if index < 0: + index = len(self._data) + index + path = self._path.append(index) + old_value = self._data[index] + self._recorder.record_replace(path, old_value, value) + self._data[index] = value + # Invalidate proxy cache for this index + if index in self._proxies: + del self._proxies[index] + + def __delitem__(self, index: Union[int, slice]) -> None: + if isinstance(index, slice): + # Handle slice deletion with proper patch generation + start, stop, step = index.indices(len(self._data)) + + if step != 1: + # For step slices, delete from end to start to maintain indices + indices = list(range(start, stop, step)) + for idx in reversed(indices): + old_value = self._data[idx] + path = self._path.append(idx) + self._recorder.record_remove(path, old_value) + del self._data[idx] + else: + # Contiguous slice - delete from end to start + old_values = list(self._data[start:stop]) + for i in range(len(old_values) - 1, -1, -1): + old_value = old_values[i] + path = self._path.append(start + i) + self._recorder.record_remove(path, old_value) + del self._data[start:stop] + + # Invalidate all proxy caches as indices shifted + self._proxies.clear() + return + + # Resolve negative indices to positive for correct paths + if index < 0: + index = len(self._data) + index + old_value = self._data[index] + path = self._path.append(index) + self._recorder.record_remove(path, old_value) + del self._data[index] + # Invalidate all proxy caches as indices shift + self._proxies.clear() + + def append(self, value: Any) -> None: + # Forward patch uses "-" (append to end), reverse patch uses actual index + forward_path = self._path.append("-") + reverse_path = self._path.append(len(self._data)) + self._recorder.record_add(forward_path, value, reverse_path) + self._data.append(value) + + def insert(self, index: int, value: Any) -> None: + # Use the index for insertion + path = self._path.append(index) + self._recorder.record_add(path, value) + self._data.insert(index, value) + # Invalidate all proxy caches as indices shift + self._proxies.clear() + + def pop(self, index: int = -1) -> Any: + if index < 0: + index = len(self._data) + index + old_value = self._data[index] + path = self._path.append(index) + # If popping from the end, the reverse (add) operation should use "-" to append + # rather than a specific index, since the index may not exist when reversing + is_last = index == len(self._data) - 1 + reverse_path = self._path.append("-") if is_last else None + self._recorder.record_remove(path, old_value, reverse_path) + result = self._data.pop(index) + # Invalidate all proxy caches as indices shift + self._proxies.clear() + return result + + def remove(self, value: Any) -> None: + index = self._data.index(value) + del self[index] + + def clear(self) -> None: + # Generate patches for all elements (from end to start for correct indices) + # All reverse patches use "-" to append, since we're restoring to an empty list + reverse_path = self._path.append("-") + for i in range(len(self._data) - 1, -1, -1): + path = self._path.append(i) + self._recorder.record_remove(path, self._data[i], reverse_path) + self._data.clear() + self._proxies.clear() + + def extend(self, values): + # Generate patches and extend data + values_list = list(values) + start_index = len(self._data) + for i, value in enumerate(values_list): + forward_path = self._path.append("-") + reverse_path = self._path.append(start_index + i) + self._recorder.record_add(forward_path, value, reverse_path) + self._data.extend(values_list) + + def reverse(self) -> None: + """Reverse the list in place and generate appropriate patches.""" + n = len(self._data) + # Reverse the underlying data + self._data.reverse() + # Generate patches for each changed position + # After reverse, element at position i came from position n-1-i + for i in range(n): + old_value = self._data[n - 1 - i] + new_value = self._data[i] + if old_value != new_value: + path = self._path.append(i) + self._recorder.record_replace(path, old_value, new_value) + # Invalidate all proxy caches as positions changed + self._proxies.clear() + + def sort(self, *args, **kwargs) -> None: + """Sort the list in place and generate appropriate patches.""" + # Record the old state + old_list = list(self._data) + # Sort the underlying data + self._data.sort(*args, **kwargs) + # Generate patches for each changed position + for i in range(len(self._data)): + if i < len(old_list) and old_list[i] != self._data[i]: + path = self._path.append(i) + self._recorder.record_replace(path, old_list[i], self._data[i]) + # Invalidate all proxy caches as positions changed + self._proxies.clear() + + +# Add simple reader methods to ListProxy +_add_reader_methods( + ListProxy, + [ + "__len__", + "__contains__", + "__repr__", + "__iter__", + "__reversed__", + "index", + "count", + "copy", + ], +) + + +class SetProxy: + """Proxy for set objects that tracks mutations and generates patches.""" + + def __init__(self, data: Set, recorder: PatchRecorder, path: Pointer): + self._data = data + self._recorder = recorder + self._path = path + + def add(self, value: Any) -> None: + if value not in self._data: + path = self._path.append("-") + reverse_path = self._path.append(value) + self._recorder.record_add(path, value, reverse_path) + self._data.add(value) + + def remove(self, value: Any) -> None: + path = self._path.append(value) + self._recorder.record_remove(path, value) + self._data.remove(value) + + def discard(self, value: Any) -> None: + if value in self._data: + path = self._path.append(value) + self._recorder.record_remove(path, value) + self._data.discard(value) + + def pop(self) -> Any: + value = self._data.pop() + path = self._path.append(value) + self._recorder.record_remove(path, value) + return value + + def clear(self) -> None: + # Generate patches for all values and clear data + for value in list(self._data): + path = self._path.append(value) + self._recorder.record_remove(path, value) + self._data.clear() + + def update(self, *others): + # Generate patches and update data + for other in others: + for value in other: + if value not in self._data: + path = self._path.append("-") + reverse_path = self._path.append(value) + self._recorder.record_add(path, value, reverse_path) + self._data.add(value) + + def __ior__(self, other): + """Implement |= operator (union update).""" + for value in other: + self.add(value) + return self + + def __iand__(self, other): + """Implement &= operator (intersection update).""" + # Remove values not in other + values_to_remove = [v for v in self._data if v not in other] + for value in values_to_remove: + self.remove(value) + return self + + def __isub__(self, other): + """Implement -= operator (difference update).""" + # Remove values that are in other + for value in other: + if value in self._data: + self.remove(value) + return self + + def __ixor__(self, other): + """Implement ^= operator (symmetric difference update).""" + # Add values from other that aren't in self, remove values that are in both + for value in other: + if value in self._data: + self.remove(value) + else: + self.add(value) + return self + + +# Add simple reader methods to SetProxy +_add_reader_methods( + SetProxy, + [ + "__len__", + "__contains__", + "__repr__", + "__iter__", + "union", + "intersection", + "difference", + "symmetric_difference", + "isdisjoint", + "issubset", + "issuperset", + "copy", + ], +) + + +def produce( + base: Any, recipe: Callable[[Any], None], in_place: bool = False +) -> Tuple[Any, List[Dict], List[Dict]]: + """ + Produce a new state by applying mutations, tracking patches along the way. + + This is an alternative to the diff() function that uses proxy objects to + track mutations in real-time instead of comparing before/after snapshots. + + Args: + base: The base object to mutate (dict, list, or set) + recipe: A function that receives a proxy-wrapped draft and mutates it + in_place: If True, mutate the original object directly (useful for + reactive objects like observ). If False (default), operate + on a deep copy and leave the original unchanged. + + Returns: + A tuple of (result, patches, reverse_patches) where: + - result: The mutated object (same as base if in_place=True) + - patches: List of patches representing the mutations + - reverse_patches: List of patches to reverse the mutations + + Example: + >>> base = {"count": 0, "items": []} + >>> def increment(draft): + ... draft["count"] += 1 + ... draft["items"].append("new") + >>> result, patches, reverse = produce(base, increment) + >>> print(result) + {"count": 1, "items": ["new"]} + >>> print(patches) + [{"op": "replace", "path": "/count", "value": 1}, + {"op": "add", "path": "/items/-", "value": "new"}] + + Example with in_place=True for reactive objects: + >>> from observ import reactive + >>> state = reactive({"count": 0}) + >>> result, patches, reverse = produce(state, lambda d: d.__setitem__("count", 5), in_place=True) + >>> # state["count"] is now 5, and watchers were triggered + """ + if in_place: + # Mutate the original object directly + # Don't unwrap or copy - use the base object as-is + draft = base + else: + # Unwrap observ reactive objects to get the underlying data + # Use observ's to_raw() function if available + if observ_to_raw is not None: + base = observ_to_raw(base) + + # Create a deep copy of the base object + draft = copy.deepcopy(base) + + # Create a patch recorder + recorder = PatchRecorder() + + # Wrap the draft in a proxy using duck typing (similar to diff()) + # This allows compatibility with observ reactive objects and other proxies + path = Pointer() + if hasattr(draft, "keys"): # dict-like + proxy = DictProxy(draft, recorder, path) + elif hasattr(draft, "append"): # list-like + proxy = ListProxy(draft, recorder, path) + elif hasattr(draft, "add"): # set-like + proxy = SetProxy(draft, recorder, path) + else: + raise TypeError(f"Unsupported type for produce: {type(draft)}") + + # Call the recipe function with the proxy + recipe(proxy) + + # Return the mutated draft and the patches + return draft, recorder.patches, recorder.reverse_patches diff --git a/pyproject.toml b/pyproject.toml index 1228cf2..e6474ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ authors = [ { name = "Korijn van Golen", email = "korijn@gmail.com" }, { name = "Berend Klein Haneveld", email = "berendkleinhaneveld@gmail.com" }, ] -requires-python = ">=3.8" +requires-python = ">=3.9" readme = "README.md" [project.urls] @@ -20,6 +20,9 @@ dev = [ "pytest-watch", "pytest-benchmark", ] +observ = [ + "observ>=0.17.0", +] [tool.ruff.lint] extend-select = [ @@ -37,3 +40,6 @@ extend-select = [ [build-system] requires = ["hatchling"] build-backend = "hatchling.build" + +[tool.pytest.ini_options] +addopts = "--benchmark-columns='mean, stddev, rounds'" diff --git a/tests/test_observ_integration.py b/tests/test_observ_integration.py new file mode 100644 index 0000000..6752435 --- /dev/null +++ b/tests/test_observ_integration.py @@ -0,0 +1,325 @@ +"""Test integration with observ reactive objects.""" + +import pytest + +try: + from observ import reactive, watch + + OBSERV_AVAILABLE = True +except ImportError: + OBSERV_AVAILABLE = False + +from patchdiff import apply, produce + +pytestmark = pytest.mark.skipif(not OBSERV_AVAILABLE, reason="observ not installed") + + +def test_produce_with_reactive_dict(): + """Test that produce() works with observ reactive dicts.""" + # Create a reactive object + state = reactive({"count": 0, "name": "Alice"}) + + def recipe(draft): + draft["count"] = 5 + draft["name"] = "Bob" + + # This should work with reactive objects + result, patches, _reverse = produce(state, recipe) + + assert result["count"] == 5 + assert result["name"] == "Bob" + assert len(patches) == 2 + + +def test_produce_with_reactive_list(): + """Test that produce() works with observ reactive lists.""" + state = reactive([1, 2, 3]) + + def recipe(draft): + draft.append(4) + draft[0] = 10 + + result, patches, _reverse = produce(state, recipe) + + assert result == [10, 2, 3, 4] + assert len(patches) == 2 + + +def test_produce_with_nested_reactive(): + """Test that produce() works with nested reactive structures.""" + state = reactive({"user": {"name": "Alice", "age": 30}}) + + def recipe(draft): + draft["user"]["age"] = 31 + draft["user"]["city"] = "NYC" + + result, patches, _reverse = produce(state, recipe) + + assert result["user"]["age"] == 31 + assert result["user"]["city"] == "NYC" + assert len(patches) == 2 + + +def test_patches_apply_to_reactive_result(): + """Test that patches generated from reactive objects can be applied.""" + state = reactive({"a": 1, "b": 2}) + + def recipe(draft): + draft["a"] = 10 + draft["c"] = 3 + + result, patches, _reverse = produce(state, recipe) + + # Apply patches to the original (non-reactive) data + original_data = {"a": 1, "b": 2} + applied = apply(original_data, patches) + + assert applied == result + + +def test_produce_does_not_affect_original_reactive(): + """Test that produce() doesn't mutate the original reactive object.""" + original_state = {"count": 0} + state = reactive(original_state) + + def recipe(draft): + draft["count"] = 10 + + result, _patches, _reverse = produce(state, recipe) + + # The result should be different + assert result["count"] == 10 + # But the original should be unchanged + assert state["count"] == 0 + assert original_state["count"] == 0 + + +def test_produce_in_place_mutates_original(): + """Test that produce(in_place=True) mutates the original object.""" + state = {"count": 0, "items": [1, 2, 3]} + + def recipe(draft): + draft["count"] = 10 + draft["items"].append(4) + + result, patches, _reverse = produce(state, recipe, in_place=True) + + # Result should be the same object + assert result is state + # Original should be mutated + assert state["count"] == 10 + assert state["items"] == [1, 2, 3, 4] + # Patches should still be generated + assert len(patches) == 2 + + +def test_produce_in_place_with_reactive_mutates_state(): + """Test that produce(in_place=True) mutates reactive objects directly.""" + state = reactive({"count": 0, "name": "Alice"}) + + def recipe(draft): + draft["count"] = 5 + draft["name"] = "Bob" + + result, patches, _reverse = produce(state, recipe, in_place=True) + + # Result should be the same object + assert result is state + # State should be mutated + assert state["count"] == 5 + assert state["name"] == "Bob" + # Patches should be generated + assert len(patches) == 2 + assert patches[0]["op"] == "replace" + assert patches[0]["value"] == 5 + + +def test_produce_in_place_with_nested_reactive(): + """Test that produce(in_place=True) works with nested reactive structures.""" + state = reactive({"user": {"name": "Alice", "age": 30}, "count": 0}) + + def recipe(draft): + draft["user"]["age"] = 31 + draft["count"] = 1 + + result, patches, _reverse = produce(state, recipe, in_place=True) + + # State should be mutated + assert state["user"]["age"] == 31 + assert state["count"] == 1 + # Result is the same object + assert result is state + # Patches should be generated + assert len(patches) == 2 + + +def test_watcher_triggered_on_in_place_dict_mutation(): + """Test that observ watchers are triggered when using produce(in_place=True) on dicts.""" + state = reactive({"count": 0, "name": "Alice"}) + changes = [] + + def callback(new_val, old_val): + changes.append(("count", new_val, old_val)) + + # Create a synchronous watcher on the count field + _watcher = watch(lambda: state["count"], callback, sync=True) + + def recipe(draft): + draft["count"] = 5 + draft["name"] = "Bob" + + _result, _patches, _reverse = produce(state, recipe, in_place=True) + + # Watcher should have been triggered + assert len(changes) == 1 + assert changes[0] == ("count", 5, 0) + + # State should be mutated + assert state["count"] == 5 + assert state["name"] == "Bob" + + +def test_watcher_triggered_on_in_place_list_mutation(): + """Test that observ watchers are triggered when using produce(in_place=True) on lists.""" + state = reactive([1, 2, 3]) + changes = [] + + def callback(new_val, old_val): + changes.append(("first", new_val, old_val)) + + # Create a synchronous watcher on the first element + _watcher = watch(lambda: state[0], callback, sync=True) + + def recipe(draft): + draft[0] = 10 + draft.append(4) + + _result, _patches, _reverse = produce(state, recipe, in_place=True) + + # Watcher should have been triggered + assert len(changes) == 1 + assert changes[0] == ("first", 10, 1) + + # State should be mutated + assert state == [10, 2, 3, 4] + + +def test_watcher_triggered_on_nested_mutation(): + """Test that observ watchers are triggered for nested mutations with produce(in_place=True).""" + state = reactive({"user": {"name": "Alice", "age": 30}}) + changes = [] + + def callback(new_val, old_val): + changes.append(("age", new_val, old_val)) + + # Create a synchronous watcher on nested field + _watcher = watch(lambda: state["user"]["age"], callback, sync=True) + + def recipe(draft): + draft["user"]["age"] = 31 + + _result, _patches, _reverse = produce(state, recipe, in_place=True) + + # Watcher should have been triggered + assert len(changes) == 1 + assert changes[0] == ("age", 31, 30) + + # State should be mutated + assert state["user"]["age"] == 31 + + +def test_watcher_not_triggered_without_in_place(): + """Test that observ watchers are NOT triggered when using produce() without in_place.""" + state = reactive({"count": 0}) + changes = [] + + def callback(new_val, old_val): + changes.append(("count", new_val, old_val)) + + # Create a synchronous watcher + _watcher = watch(lambda: state["count"], callback, sync=True) + + def recipe(draft): + draft["count"] = 5 + + # Without in_place=True, the original state should not be mutated + result, _patches, _reverse = produce(state, recipe) + + # Watcher should NOT have been triggered (original not mutated) + assert len(changes) == 0 + + # Original state should be unchanged + assert state["count"] == 0 + # But result should have the new value + assert result["count"] == 5 + + +def test_multiple_watchers_triggered(): + """Test that multiple watchers are all triggered on in_place mutations.""" + state = reactive({"a": 1, "b": 2}) + changes_a = [] + changes_b = [] + + def callback_a(new_val, old_val): + changes_a.append((new_val, old_val)) + + def callback_b(new_val, old_val): + changes_b.append((new_val, old_val)) + + # Create synchronous watchers on both fields + _watcher_a = watch(lambda: state["a"], callback_a, sync=True) + _watcher_b = watch(lambda: state["b"], callback_b, sync=True) + + def recipe(draft): + draft["a"] = 10 + draft["b"] = 20 + + _result, _patches, _reverse = produce(state, recipe, in_place=True) + + # Both watchers should have been triggered + assert len(changes_a) == 1 + assert changes_a[0] == (10, 1) + assert len(changes_b) == 1 + assert changes_b[0] == (20, 2) + + +def test_watcher_triggered_on_list_append(): + """Test that observ watchers are triggered when appending to a list with in_place.""" + state = reactive({"items": [1, 2, 3]}) + changes = [] + + def callback(new_val, old_val): + changes.append(("length", new_val, old_val)) + + # Watch the length of the list + _watcher = watch(lambda: len(state["items"]), callback, sync=True) + + def recipe(draft): + draft["items"].append(4) + draft["items"].append(5) + + _result, _patches, _reverse = produce(state, recipe, in_place=True) + + # Watcher should have been triggered (possibly multiple times for each append) + assert len(changes) >= 1 + # Final length should be 5 + assert len(state["items"]) == 5 + assert state["items"] == [1, 2, 3, 4, 5] + + +def test_produce_in_place_with_reactive_list(): + """Test that produce(in_place=True) works with reactive lists.""" + state = reactive([1, 2, 3]) + + def recipe(draft): + draft.append(4) + draft[0] = 10 + + result, patches, _reverse = produce(state, recipe, in_place=True) + + # State should be mutated + assert state == [10, 2, 3, 4] + # Result is the same object + assert result is state + # Patches should be generated + assert len(patches) == 2 diff --git a/tests/test_produce_core.py b/tests/test_produce_core.py new file mode 100644 index 0000000..9dff110 --- /dev/null +++ b/tests/test_produce_core.py @@ -0,0 +1,795 @@ +"""Core tests for produce() function - complex scenarios and edge cases.""" + +import pytest + +from patchdiff import apply, produce + + +def assert_patches_work(base, recipe): + """Helper to verify that patches and reverse patches work correctly. + + This applies the recipe, then verifies: + 1. Applying patches to base produces the result + 2. Applying reverse patches to result produces the base + """ + import copy + + base_copy = copy.deepcopy(base) + + result, patches, reverse = produce(base, recipe) + + # Verify patches transform base to result + applied = apply(base_copy, patches) + assert applied == result, f"Patches failed: {patches}" + + # Verify reverse patches transform result back to base + reverted = apply(result, reverse) + assert reverted == base_copy, f"Reverse patches failed: {reverse}" + + return result, patches, reverse + + +def test_deeply_nested_mutation(): + """Test mutations on deeply nested structures.""" + base = {"a": {"b": {"c": [1, 2, 3]}}} + + def recipe(draft): + draft["a"]["b"]["c"].append(4) + draft["a"]["b"]["d"] = "new" + + result, patches, _reverse = produce(base, recipe) + + assert result == {"a": {"b": {"c": [1, 2, 3, 4], "d": "new"}}} + assert len(patches) == 2 + + +def test_mixed_operations(): + """Test mixed operations across different types.""" + base = {"users": [{"name": "Alice", "tags": {"python", "js"}}]} + + def recipe(draft): + draft["users"][0]["name"] = "Bob" + draft["users"][0]["tags"].add("rust") + draft["users"].append({"name": "Charlie", "tags": set()}) + + result, patches, _reverse = produce(base, recipe) + + assert result["users"][0]["name"] == "Bob" + assert "rust" in result["users"][0]["tags"] + assert len(result["users"]) == 2 + assert len(patches) == 3 + + +def test_original_unchanged(): + """Test that the original object is not mutated.""" + base = {"a": 1, "b": [1, 2], "c": {3, 4}} + + def recipe(draft): + draft["a"] = 10 + draft["b"].append(3) + draft["c"].add(5) + + result, _patches, _reverse = produce(base, recipe) + + # Original should be unchanged + assert base == {"a": 1, "b": [1, 2], "c": {3, 4}} + # Result should have mutations + assert result == {"a": 10, "b": [1, 2, 3], "c": {3, 4, 5}} + + +def test_patches_apply_correctly(): + """Test that generated patches can be applied using apply().""" + base = {"count": 0, "items": []} + + def recipe(draft): + draft["count"] = 5 + draft["items"].extend([1, 2, 3]) + + result, patches, _reverse = produce(base, recipe) + + # Apply patches to base should give us the result + applied = apply(base, patches) + assert applied == result + + +def test_reverse_patches_work(): + """Test that reverse patches correctly undo changes.""" + base = {"a": 1, "b": 2} + + def recipe(draft): + draft["a"] = 10 + draft["c"] = 3 + del draft["b"] + + result, _patches, reverse = produce(base, recipe) + + # Apply reverse patches to result should give us base + reverted = apply(result, reverse) + assert reverted == base + + +def test_empty_recipe(): + """Test produce with no mutations.""" + base = {"a": 1} + + def recipe(draft): + pass # No mutations + + result, patches, reverse = produce(base, recipe) + + assert result == base + assert patches == [] + assert reverse == [] + + +def test_unsupported_type(): + """Test produce with unsupported base type.""" + base = "string" + + def recipe(draft): + pass + + with pytest.raises(TypeError): + produce(base, recipe) + + +def test_reading_nested_values(): + """Test that reading nested values works correctly.""" + base = {"user": {"name": "Alice", "age": 30}} + + def recipe(draft): + # Read nested value + name = draft["user"]["name"] + assert name == "Alice" + # Modify based on read value + draft["user"]["name"] = name.upper() + + result, _patches, _reverse = produce(base, recipe) + + assert result["user"]["name"] == "ALICE" + + +def test_no_patch_for_same_value_dict(): + """Test that setting a dict value to the same value produces no patch.""" + base = {"a": 1, "b": 2} + + def recipe(draft): + draft["a"] = 1 # Same value + + result, patches, reverse = produce(base, recipe) + + assert result == {"a": 1, "b": 2} + assert patches == [], f"Expected no patches, got {patches}" + assert reverse == [] + + +def test_no_patch_for_same_value_list(): + """Test that setting a list item to the same value produces no patch.""" + base = [1, 2, 3] + + def recipe(draft): + draft[1] = 2 # Same value + + result, patches, reverse = produce(base, recipe) + + assert result == [1, 2, 3] + assert patches == [], f"Expected no patches, got {patches}" + assert reverse == [] + + +def test_no_patch_for_same_nested_value(): + """Test that setting a nested value to the same value produces no patch.""" + base = {"user": {"name": "Alice", "age": 30}} + + def recipe(draft): + draft["user"]["name"] = "Alice" # Same value + draft["user"]["age"] = 30 # Same value + + result, patches, reverse = produce(base, recipe) + + assert result == base + assert patches == [], f"Expected no patches, got {patches}" + assert reverse == [] + + +def test_no_patch_for_update_with_same_values(): + """Test that dict.update() with same values produces no patches.""" + base = {"a": 1, "b": 2} + + def recipe(draft): + draft.update({"a": 1, "b": 2}) # Same values + + result, patches, reverse = produce(base, recipe) + + assert result == base + assert patches == [], f"Expected no patches, got {patches}" + assert reverse == [] + + +def test_no_patch_for_list_slice_same_values(): + """Test that slice assignment with same values produces no patches.""" + base = [1, 2, 3, 4, 5] + + def recipe(draft): + draft[1:3] = [2, 3] # Same values + + result, patches, reverse = produce(base, recipe) + + assert result == base + assert patches == [], f"Expected no patches, got {patches}" + assert reverse == [] + + +def test_partial_patch_for_mixed_changes(): + """Test that only actual changes produce patches.""" + base = {"a": 1, "b": 2, "c": 3} + + def recipe(draft): + draft["a"] = 1 # Same value - no patch + draft["b"] = 20 # Different value - should patch + draft["c"] = 3 # Same value - no patch + + result, patches, _reverse = produce(base, recipe) + + assert result == {"a": 1, "b": 20, "c": 3} + assert len(patches) == 1 + assert patches[0]["op"] == "replace" + assert patches[0]["path"].tokens == ("b",) + assert patches[0]["value"] == 20 + + +# ============================================================================= +# Tests verifying patches and reverse patches actually work when applied +# ============================================================================= + + +def test_dict_operations_patches_apply(): + """Test that dict operation patches can be applied correctly.""" + base = {"a": 1, "b": 2} + + def recipe(draft): + draft["a"] = 10 # replace + draft["c"] = 3 # add + del draft["b"] # remove + + assert_patches_work(base, recipe) + + +def test_dict_update_patches_apply(): + """Test that dict.update() patches can be applied correctly.""" + base = {"a": 1} + + def recipe(draft): + draft.update({"b": 2, "c": 3}) + + assert_patches_work(base, recipe) + + +def test_dict_pop_patches_apply(): + """Test that dict.pop() patches can be applied correctly.""" + base = {"a": 1, "b": 2, "c": 3} + + def recipe(draft): + draft.pop("b") + + assert_patches_work(base, recipe) + + +def test_dict_clear_patches_apply(): + """Test that dict.clear() patches can be applied correctly.""" + base = {"a": 1, "b": 2, "c": 3} + + def recipe(draft): + draft.clear() + + assert_patches_work(base, recipe) + + +def test_list_append_patches_apply(): + """Test that list.append() patches can be applied correctly.""" + base = [1, 2, 3] + + def recipe(draft): + draft.append(4) + draft.append(5) + + assert_patches_work(base, recipe) + + +def test_list_insert_patches_apply(): + """Test that list.insert() patches can be applied correctly.""" + base = [1, 2, 3] + + def recipe(draft): + draft.insert(1, 10) + + assert_patches_work(base, recipe) + + +def test_list_pop_patches_apply(): + """Test that list.pop() patches can be applied correctly.""" + base = [1, 2, 3, 4] + + def recipe(draft): + draft.pop() + draft.pop(0) + + assert_patches_work(base, recipe) + + +def test_list_remove_patches_apply(): + """Test that list.remove() patches can be applied correctly.""" + base = [1, 2, 3, 2, 4] + + def recipe(draft): + draft.remove(2) + + assert_patches_work(base, recipe) + + +def test_list_setitem_patches_apply(): + """Test that list setitem patches can be applied correctly.""" + base = [1, 2, 3] + + def recipe(draft): + draft[0] = 10 + draft[-1] = 30 + + assert_patches_work(base, recipe) + + +def test_list_delitem_patches_apply(): + """Test that list delitem patches can be applied correctly.""" + base = [1, 2, 3, 4, 5] + + def recipe(draft): + del draft[2] + + assert_patches_work(base, recipe) + + +def test_list_extend_patches_apply(): + """Test that list.extend() patches can be applied correctly.""" + base = [1, 2] + + def recipe(draft): + draft.extend([3, 4, 5]) + + assert_patches_work(base, recipe) + + +def test_list_clear_patches_apply(): + """Test that list.clear() patches can be applied correctly.""" + base = [1, 2, 3] + + def recipe(draft): + draft.clear() + + assert_patches_work(base, recipe) + + +def test_list_slice_setitem_patches_apply(): + """Test that list slice assignment patches can be applied correctly.""" + base = [1, 2, 3, 4, 5] + + def recipe(draft): + draft[1:3] = [20, 30, 40] + + assert_patches_work(base, recipe) + + +def test_list_slice_delitem_patches_apply(): + """Test that list slice deletion patches can be applied correctly.""" + base = [1, 2, 3, 4, 5] + + def recipe(draft): + del draft[1:4] + + assert_patches_work(base, recipe) + + +def test_list_reverse_patches_apply(): + """Test that list.reverse() patches can be applied correctly.""" + base = [1, 2, 3, 4] + + def recipe(draft): + draft.reverse() + + assert_patches_work(base, recipe) + + +def test_list_sort_patches_apply(): + """Test that list.sort() patches can be applied correctly.""" + base = [3, 1, 4, 1, 5, 9, 2, 6] + + def recipe(draft): + draft.sort() + + assert_patches_work(base, recipe) + + +def test_set_add_patches_apply(): + """Test that set.add() patches can be applied correctly.""" + base = {1, 2, 3} + + def recipe(draft): + draft.add(4) + draft.add(5) + + assert_patches_work(base, recipe) + + +def test_set_remove_patches_apply(): + """Test that set.remove() patches can be applied correctly.""" + base = {1, 2, 3, 4} + + def recipe(draft): + draft.remove(2) + + assert_patches_work(base, recipe) + + +def test_set_discard_patches_apply(): + """Test that set.discard() patches can be applied correctly.""" + base = {1, 2, 3} + + def recipe(draft): + draft.discard(2) + draft.discard(10) # Not present, should be no-op + + assert_patches_work(base, recipe) + + +def test_set_clear_patches_apply(): + """Test that set.clear() patches can be applied correctly.""" + base = {1, 2, 3} + + def recipe(draft): + draft.clear() + + assert_patches_work(base, recipe) + + +def test_set_update_patches_apply(): + """Test that set.update() patches can be applied correctly.""" + base = {1, 2} + + def recipe(draft): + draft.update({3, 4, 5}) + + assert_patches_work(base, recipe) + + +def test_set_operators_patches_apply(): + """Test that set operator patches can be applied correctly.""" + base = {1, 2, 3, 4} + + def recipe(draft): + draft |= {5, 6} # union + draft -= {1} # difference + draft &= {2, 3, 5, 6} # intersection + + assert_patches_work(base, recipe) + + +def test_nested_operations_patches_apply(): + """Test that nested structure patches can be applied correctly.""" + base = { + "users": [ + {"name": "Alice", "tags": {1, 2}}, + {"name": "Bob", "tags": {3, 4}}, + ], + "count": 2, + } + + def recipe(draft): + draft["users"][0]["name"] = "Alicia" + draft["users"][0]["tags"].add(5) + draft["users"].append({"name": "Charlie", "tags": set()}) + draft["count"] = 3 + + assert_patches_work(base, recipe) + + +def test_complex_list_operations_patches_apply(): + """Test complex list operations produce correct patches.""" + base = [{"id": 1}, {"id": 2}, {"id": 3}] + + def recipe(draft): + draft[0]["id"] = 10 + draft.pop(1) + draft.append({"id": 4}) + + assert_patches_work(base, recipe) + + +def test_deeply_nested_mixed_types_many_operations(): + """Test deeply nested structure with all three proxy types and many operations.""" + base = { + "organization": { + "departments": [ + { + "name": "Engineering", + "teams": [ + {"name": "Backend", "members": {"alice", "bob"}}, + {"name": "Frontend", "members": {"charlie", "diana"}}, + ], + }, + { + "name": "Sales", + "teams": [{"name": "Enterprise", "members": {"eve", "frank"}}], + }, + ], + "metadata": {"founded": 2020, "tags": ["tech", "startup"]}, + } + } + + def recipe(draft): + # 1. Modify deeply nested value + draft["organization"]["metadata"]["founded"] = 2021 + + # 2. Add to deeply nested set + draft["organization"]["departments"][0]["teams"][0]["members"].add("grace") + + # 3. Remove from deeply nested set + draft["organization"]["departments"][0]["teams"][0]["members"].remove("bob") + + # 4. Modify nested dict + draft["organization"]["departments"][0]["name"] = "Engineering & Product" + + # 5. Append to nested list + draft["organization"]["metadata"]["tags"].append("AI") + + # 6. Insert into nested list + draft["organization"]["metadata"]["tags"].insert(0, "innovative") + + # 7. Add new team to nested list + draft["organization"]["departments"][0]["teams"].append( + {"name": "DevOps", "members": {"henry"}} + ) + + # 8. Remove item from nested list + draft["organization"]["departments"].pop(1) + + # 9. Update nested dict + draft["organization"]["metadata"].update({"employees": 50, "remote": True}) + + # 10. Clear and repopulate nested set + draft["organization"]["departments"][0]["teams"][1]["members"].clear() + draft["organization"]["departments"][0]["teams"][1]["members"].add("new_member") + + result, patches, reverse = produce(base, recipe) + + # Verify result + assert result["organization"]["metadata"]["founded"] == 2021 + assert "grace" in result["organization"]["departments"][0]["teams"][0]["members"] + assert "bob" not in result["organization"]["departments"][0]["teams"][0]["members"] + assert result["organization"]["departments"][0]["name"] == "Engineering & Product" + assert result["organization"]["metadata"]["tags"][0] == "innovative" + assert "AI" in result["organization"]["metadata"]["tags"] + assert len(result["organization"]["departments"][0]["teams"]) == 3 + assert len(result["organization"]["departments"]) == 1 # Sales removed + assert result["organization"]["metadata"]["employees"] == 50 + + # Verify patches were generated + assert len(patches) > 10 + + # Verify patches can be applied + from patchdiff import apply + + patched = apply(base, patches) + assert patched == result + + # Verify reverse patches work + reversed_result = apply(result, reverse) + assert reversed_result == base + + +def test_list_of_dicts_with_sets_comprehensive(): + """Test list of dicts containing sets with comprehensive operations.""" + base = [ + {"id": 1, "tags": {"python", "async"}, "scores": [85, 90, 92]}, + {"id": 2, "tags": {"javascript", "react"}, "scores": [78, 82]}, + {"id": 3, "tags": {"python", "django"}, "scores": [95, 88, 91]}, + ] + + def recipe(draft): + # Operations on first item + draft[0]["tags"].add("fastapi") + draft[0]["tags"].discard("async") + draft[0]["scores"].append(88) + draft[0]["scores"][0] = 87 + + # Operations on second item + draft[1]["id"] = 20 + draft[1]["tags"] |= {"typescript", "redux"} + draft[1]["scores"].extend([85, 90]) + + # Operations on third item + draft[2]["tags"].remove("django") + draft[2]["scores"].reverse() + draft[2]["scores"].pop() + + # List-level operations + draft.append({"id": 4, "tags": {"go", "kubernetes"}, "scores": [92]}) + draft.insert(1, {"id": 5, "tags": set(), "scores": []}) + + # Slice operations + draft[3:5] = [{"id": 6, "tags": {"rust"}, "scores": [100]}] + + result, patches, _reverse = produce(base, recipe) + + # Verify complex nested modifications + # Index 0: modified first item + assert "fastapi" in result[0]["tags"] + assert "async" not in result[0]["tags"] + assert result[0]["scores"] == [87, 90, 92, 88] + + # Index 1: inserted item (inserted at position 1) + assert result[1]["id"] == 5 + assert result[1]["tags"] == set() + + # Index 2: modified second item (shifted by insert) + assert result[2]["id"] == 20 + assert "typescript" in result[2]["tags"] + assert len(result[2]["scores"]) == 4 + + # Index 3: slice replacement result + assert result[3]["id"] == 6 + assert result[3]["tags"] == {"rust"} + + # Total length after all operations + assert len(result) == 4 + + assert len(patches) > 15 + + +def test_dict_with_nested_lists_and_operations(): + """Test dict containing nested lists with slice operations and sorting.""" + base = { + "matrix": [[1, 2, 3], [4, 5, 6], [7, 8, 9]], + "names": ["charlie", "alice", "bob"], + "records": [{"x": 3}, {"x": 1}, {"x": 2}], + } + + def recipe(draft): + # Slice operations on nested lists + draft["matrix"][0][1:2] = [20] + draft["matrix"][1][:] = [40, 50, 60] + draft["matrix"][2].append(10) + + # List operations + draft["names"].sort() + draft["names"].insert(0, "zara") + + # Complex operations on list of dicts + draft["records"].sort(key=lambda r: r["x"]) + draft["records"][0]["x"] = 10 + draft["records"].append({"x": 4}) + + # Replace entire nested list + draft["matrix"].append([11, 12, 13]) + + result, patches, _reverse = produce(base, recipe) + + assert result["matrix"][0] == [1, 20, 3] + assert result["matrix"][1] == [40, 50, 60] + assert result["matrix"][2] == [7, 8, 9, 10] + assert result["names"] == ["zara", "alice", "bob", "charlie"] + assert result["records"][0]["x"] == 10 + assert result["records"][-1]["x"] == 4 + assert len(result["matrix"]) == 4 + + assert len(patches) > 10 + + +def test_set_operations_in_nested_structures(): + """Test various set operations within nested structures.""" + base = { + "groups": [ + {"name": "admins", "users": {"alice", "bob"}}, + {"name": "users", "users": {"charlie", "diana", "eve"}}, + {"name": "guests", "users": {"frank"}}, + ], + "all_users": {"alice", "bob", "charlie", "diana", "eve", "frank"}, + } + + def recipe(draft): + # Set operations on nested sets + draft["groups"][0]["users"].add("grace") + draft["groups"][0]["users"] |= {"henry", "iris"} # update operator + + # Set arithmetic on nested sets + draft["groups"][1]["users"] &= {"charlie", "eve"} # intersection + draft["groups"][2]["users"].clear() + draft["groups"][2]["users"].update(["zara", "yolanda"]) + + # Operations on top-level set + draft["all_users"].add("grace") + draft["all_users"] -= {"frank"} # difference update via operator + + # List operations + draft["groups"].pop(1) + draft["groups"].append({"name": "moderators", "users": {"alice"}}) + + result, patches, _reverse = produce(base, recipe) + + assert "grace" in result["groups"][0]["users"] + assert len(result["groups"][0]["users"]) == 5 # alice, bob, grace, henry, iris + assert result["groups"][1]["users"] == {"zara", "yolanda"} + assert len(result["groups"]) == 3 + assert "grace" in result["all_users"] + assert "frank" not in result["all_users"] + + assert len(patches) > 8 + + +def test_mixed_operations_with_slice_and_bulk_updates(): + """Test mixed operations including slicing and bulk updates on nested structures.""" + base = { + "data": { + "values": [10, 20, 30, 40, 50], + "metadata": {"count": 5, "sum": 150}, + }, + "backup": [[1, 2], [3, 4], [5, 6]], + } + + def recipe(draft): + # Slice operations on nested list + draft["data"]["values"][1:4] = [200, 300] # Shrink + draft["data"]["values"].extend([60, 70]) + + # Bulk update on nested dict + draft["data"]["metadata"].update({"count": 5, "sum": 630, "avg": 126}) + + # Operations on nested list of lists + draft["backup"][0].extend([3, 4, 5]) + draft["backup"][1].clear() + draft["backup"][1].extend([30, 40]) + draft["backup"].append([7, 8, 9]) + + # Replace nested structure + draft["data"]["extra"] = {"new": True} + + result, patches, _reverse = produce(base, recipe) + + assert result["data"]["values"] == [10, 200, 300, 50, 60, 70] + assert result["data"]["metadata"]["avg"] == 126 + assert result["backup"][0] == [1, 2, 3, 4, 5] + assert result["backup"][1] == [30, 40] + assert len(result["backup"]) == 4 + assert result["data"]["extra"]["new"] is True + + assert len(patches) > 12 + + +def test_cross_level_modifications(): + """Test modifications at multiple nesting levels simultaneously.""" + base = { + "level1": { + "level2": {"level3": {"level4": {"value": 1, "items": [1, 2, 3]}}}, + "sibling": [{"a": 1}, {"b": 2}], + }, + "top": "unchanged", + } + + def recipe(draft): + # Modify at different levels + draft["top"] = "changed" # Level 0 + draft["level1"]["sibling"].append({"c": 3}) # Level 2 + draft["level1"]["level2"]["level3"]["level4"]["value"] = 100 # Level 4 + draft["level1"]["level2"]["level3"]["level4"]["items"].reverse() # Level 4 + draft["level1"]["level2"]["level3"]["level4"]["items"].append(4) # Level 4 + draft["level1"]["level2"]["new_key"] = "new_value" # Level 2 + draft["level1"]["sibling"][0]["a"] = 10 # Level 3 + + result, patches, _reverse = produce(base, recipe) + + assert result["top"] == "changed" + assert len(result["level1"]["sibling"]) == 3 + assert result["level1"]["level2"]["level3"]["level4"]["value"] == 100 + assert result["level1"]["level2"]["level3"]["level4"]["items"] == [3, 2, 1, 4] + assert result["level1"]["level2"]["new_key"] == "new_value" + assert result["level1"]["sibling"][0]["a"] == 10 + + assert len(patches) >= 7 diff --git a/tests/test_produce_dict.py b/tests/test_produce_dict.py new file mode 100644 index 0000000..f785a82 --- /dev/null +++ b/tests/test_produce_dict.py @@ -0,0 +1,580 @@ +"""Tests for DictProxy operations in produce().""" + +import pytest + +from patchdiff import produce +from patchdiff.pointer import Pointer + + +def test_dict_add_key(): + """Test adding a new key to a dict.""" + base = {"a": 1} + + def recipe(draft): + draft["b"] = 2 + + result, patches, reverse = produce(base, recipe) + + assert result == {"a": 1, "b": 2} + assert len(patches) == 1 + assert patches[0] == {"op": "add", "path": Pointer(["b"]), "value": 2} + assert len(reverse) == 1 + assert reverse[0] == {"op": "remove", "path": Pointer(["b"])} + + +def test_dict_remove_key(): + """Test removing a key from a dict.""" + base = {"a": 1, "b": 2} + + def recipe(draft): + del draft["b"] + + result, patches, reverse = produce(base, recipe) + + assert result == {"a": 1} + assert len(patches) == 1 + assert patches[0] == {"op": "remove", "path": Pointer(["b"])} + assert len(reverse) == 1 + assert reverse[0] == {"op": "add", "path": Pointer(["b"]), "value": 2} + + +def test_dict_replace_value(): + """Test replacing a value in a dict.""" + base = {"a": 1} + + def recipe(draft): + draft["a"] = 2 + + result, patches, reverse = produce(base, recipe) + + assert result == {"a": 2} + assert len(patches) == 1 + assert patches[0] == {"op": "replace", "path": Pointer(["a"]), "value": 2} + assert len(reverse) == 1 + assert reverse[0] == {"op": "replace", "path": Pointer(["a"]), "value": 1} + + +def test_dict_multiple_operations(): + """Test multiple operations on a dict.""" + base = {"a": 1, "b": 2} + + def recipe(draft): + draft["a"] = 10 + draft["c"] = 3 + del draft["b"] + + result, patches, _reverse = produce(base, recipe) + + assert result == {"a": 10, "c": 3} + assert len(patches) == 3 + + +def test_dict_nested(): + """Test operations on nested dicts.""" + base = {"user": {"name": "Alice", "age": 30}} + + def recipe(draft): + draft["user"]["age"] = 31 + draft["user"]["city"] = "NYC" + + result, patches, _reverse = produce(base, recipe) + + assert result == {"user": {"name": "Alice", "age": 31, "city": "NYC"}} + assert len(patches) == 2 + # Check that patches have correct nested paths + age_patch = next(p for p in patches if "age" in p["path"].tokens) + assert age_patch["path"] == Pointer(["user", "age"]) + assert age_patch["value"] == 31 + + +def test_dict_pop(): + """Test dict.pop() operation.""" + base = {"a": 1, "b": 2} + + def recipe(draft): + value = draft.pop("b") + assert value == 2 + + result, patches, _reverse = produce(base, recipe) + + assert result == {"a": 1} + assert len(patches) == 1 + assert patches[0]["op"] == "remove" + + +def test_dict_update(): + """Test dict.update() operation.""" + base = {"a": 1} + + def recipe(draft): + draft.update({"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_setdefault(): + """Test dict.setdefault() operation.""" + base = {"a": 1} + + def recipe(draft): + draft.setdefault("b", 2) + draft.setdefault("a", 10) # Should not change + + result, patches, _reverse = produce(base, recipe) + + assert result == {"a": 1, "b": 2} + assert len(patches) == 1 # Only "b" was added + + +def test_dict_clear(): + """Test dict.clear() operation.""" + base = {"a": 1, "b": 2, "c": 3} + + def recipe(draft): + draft.clear() + + result, patches, _reverse = produce(base, recipe) + + assert result == {} + assert len(patches) == 3 # All keys removed + + +def test_dict_contains(): + """Test __contains__ (in operator) on dict proxy.""" + base = {"a": 1, "b": 2} + + def recipe(draft): + assert "a" in draft + assert "c" not in draft + draft["c"] = 3 + + result, _patches, _reverse = produce(base, recipe) + + assert result == {"a": 1, "b": 2, "c": 3} + + +def test_dict_len(): + """Test __len__ on dict proxy.""" + base = {"a": 1, "b": 2} + + def recipe(draft): + assert len(draft) == 2 + draft["c"] = 3 + assert len(draft) == 3 + + result, _patches, _reverse = produce(base, recipe) + + assert len(result) == 3 + + +def test_dict_keys(): + """Test keys() method on dict proxy.""" + base = {"a": 1, "b": 2} + + def recipe(draft): + keys = list(draft.keys()) + assert "a" in keys + assert "b" in keys + draft["c"] = 3 + + result, _patches, _reverse = produce(base, recipe) + + assert "c" in result.keys() + + +def test_dict_items(): + """Test items() method on dict proxy.""" + base = {"a": 1, "b": 2} + + def recipe(draft): + items = list(draft.items()) + assert ("a", 1) in items + draft["c"] = 3 + + result, _patches, _reverse = produce(base, recipe) + + assert ("c", 3) in result.items() + + +def test_dict_get_existing_key(): + """Test get() with existing key.""" + base = {"a": 1} + + def recipe(draft): + value = draft.get("a") + assert value == 1 + draft["b"] = value + 1 + + result, _patches, _reverse = produce(base, recipe) + + assert result["b"] == 2 + + +def test_dict_get_missing_key_default(): + """Test get() with missing key and default.""" + base = {"a": 1} + + def recipe(draft): + value = draft.get("missing", 99) + assert value == 99 + draft["b"] = value + + result, _patches, _reverse = produce(base, recipe) + + assert result["b"] == 99 + + +def test_dict_get_missing_key_no_default(): + """Test get() with missing key and no default.""" + base = {"a": 1} + + def recipe(draft): + value = draft.get("missing") + assert value is None + + result, _patches, _reverse = produce(base, recipe) + + assert result == {"a": 1} + + +def test_dict_pop_with_default(): + """Test pop() with default value.""" + base = {"a": 1} + + def recipe(draft): + value = draft.pop("missing", 99) + assert value == 99 + + result, patches, _reverse = produce(base, recipe) + + assert result == {"a": 1} + assert len(patches) == 0 # No mutations + + +def test_dict_pop_missing_key_no_default(): + """Test pop() with missing key and no default raises KeyError.""" + base = {"a": 1} + + def recipe(draft): + draft.pop("missing") + + with pytest.raises(KeyError): + produce(base, recipe) + + +def test_dict_popitem(): + """Test popitem() method on dict proxy.""" + base = {"a": 1, "b": 2} + + def recipe(draft): + key, value = draft.popitem() + assert key in ("a", "b") + assert value in (1, 2) + + result, patches, _reverse = produce(base, recipe) + + assert len(result) == 1 + assert len(patches) == 1 + assert patches[0]["op"] == "remove" + + +def test_dict_popitem_empty(): + """Test popitem() on empty dict raises KeyError.""" + base = {} + + def recipe(draft): + draft.popitem() + + with pytest.raises(KeyError): + produce(base, recipe) + + +def test_dict_iter(): + """Test iterating over dict keys.""" + base = {"a": 1, "b": 2, "c": 3} + + def recipe(draft): + keys = [] + for key in draft: + keys.append(key) + assert set(keys) == {"a", "b", "c"} + + result, _patches, _reverse = produce(base, recipe) + + assert result == base + + +def test_dict_values(): + """Test iterating over dict values.""" + base = {"a": 1, "b": 2, "c": 3} + + def recipe(draft): + total = sum(draft.values()) + draft["total"] = total + + result, _patches, _reverse = produce(base, recipe) + + assert result["total"] == 6 + + +def test_dict_reversed(): + """Test __reversed__ on dict proxy.""" + base = {"a": 1, "b": 2, "c": 3} + + def recipe(draft): + keys = list(reversed(draft)) + # Verify reversed returns keys in reverse insertion order + assert keys == ["c", "b", "a"] + + result, patches, _reverse = produce(base, recipe) + + # No mutations, so no patches + assert patches == [] + assert result == base + + +def test_dict_copy(): + """Test copy() method on dict proxy.""" + base = {"a": 1, "b": 2} + + def recipe(draft): + copied = draft.copy() + # Verify copy returns a real dict with same contents + assert copied == {"a": 1, "b": 2} + assert isinstance(copied, dict) + # Verify it's a different object (not a proxy) + copied["new"] = 3 + # This mutation is on the copy, not the draft + + result, patches, _reverse = produce(base, recipe) + + # No mutations to draft, so no patches + assert patches == [] + assert result == base + + +def test_dict_setitem_invalidates_proxy_cache(): + """Test that __setitem__ invalidates the proxy cache for nested structures. + + When a nested structure is accessed and then replaced, the old proxy + should be invalidated so subsequent access returns a new proxy. + """ + base = {"nested": {"a": 1}} + + def recipe(draft): + # Access nested to create a proxy cache entry + _ = draft["nested"]["a"] + # Replace the nested structure entirely + draft["nested"] = {"b": 2} + # Access again - should get new structure, not cached proxy + assert dict(draft["nested"]) == {"b": 2} + + result, _patches, _reverse = produce(base, recipe) + + assert result == {"nested": {"b": 2}} + + +def test_dict_delitem_invalidates_proxy_cache(): + """Test that __delitem__ invalidates the proxy cache.""" + base = {"nested": {"a": 1}, "other": 2} + + def recipe(draft): + # Access nested to create a proxy cache entry + _ = draft["nested"]["a"] + # Delete the nested key + del draft["nested"] + # Verify it's gone + assert "nested" not in draft + + result, _patches, _reverse = produce(base, recipe) + + assert result == {"other": 2} + + +def test_dict_update_replaces_existing_keys(): + """Test that update() correctly replaces existing keys and invalidates cache.""" + base = {"a": {"x": 1}, "b": 2} + + def recipe(draft): + # Access nested to create proxy cache + _ = draft["a"]["x"] + # Update with new values for existing keys + draft.update({"a": {"y": 2}, "b": 3, "c": 4}) + # Verify the updates + assert dict(draft["a"]) == {"y": 2} + assert draft["b"] == 3 + assert draft["c"] == 4 + + result, patches, _reverse = produce(base, recipe) + + assert result == {"a": {"y": 2}, "b": 3, "c": 4} + # Should have: replace a, replace b, add c + assert len(patches) == 3 + + +def test_dict_update_with_iterable_of_pairs(): + """Test that update() works with an iterable of key-value pairs.""" + base = {"a": 1} + + def recipe(draft): + # Update with a list of tuples instead of a dict + draft.update([("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_update_with_kwargs(): + """Test that update() works with keyword arguments.""" + base = {"a": 1} + + def recipe(draft): + draft.update(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_update_with_dict_and_kwargs(): + """Test that update() works with both dict and keyword arguments.""" + base = {"a": 1} + + def recipe(draft): + draft.update({"b": 2}, c=3, d=4) + + result, patches, _reverse = produce(base, recipe) + + assert result == {"a": 1, "b": 2, "c": 3, "d": 4} + assert len(patches) == 3 + + +def test_dict_update_empty(): + """Test that update() with empty dict/no args is a no-op.""" + base = {"a": 1, "b": 2} + + def recipe(draft): + draft.update({}) + draft.update() + + result, patches, _reverse = produce(base, recipe) + + assert result == {"a": 1, "b": 2} + assert len(patches) == 0 + + +def test_dict_setdefault_none_implicit(): + """Test setdefault() with implicit None default.""" + base = {"a": 1} + + def recipe(draft): + value = draft.setdefault("b") + assert value is None + + result, patches, _reverse = produce(base, recipe) + + assert result == {"a": 1, "b": None} + assert len(patches) == 1 + assert patches[0]["value"] is None + + +def test_dict_setdefault_none_explicit(): + """Test setdefault() with explicit None default.""" + base = {"a": 1} + + def recipe(draft): + value = draft.setdefault("b", None) + assert value is None + + result, patches, _reverse = produce(base, recipe) + + assert result == {"a": 1, "b": None} + assert len(patches) == 1 + assert patches[0]["value"] is None + + +def test_dict_setdefault_return_value(): + """Test that setdefault() returns the correct value.""" + base = {"a": 1} + + def recipe(draft): + # Return existing value + val1 = draft.setdefault("a", 999) + assert val1 == 1 + + # Return new default value + val2 = draft.setdefault("b", 2) + assert val2 == 2 + + result, _patches, _reverse = produce(base, recipe) + + assert result == {"a": 1, "b": 2} + + +def test_dict_clear_empty(): + """Test clear() on an already empty dict.""" + base = {} + + def recipe(draft): + draft.clear() + + result, patches, _reverse = produce(base, recipe) + + assert result == {} + assert len(patches) == 0 + + +def test_dict_popitem_lifo_order(): + """Test that popitem() removes items in LIFO (last inserted) order.""" + base = {} + + def recipe(draft): + # Add items in order + draft["a"] = 1 + draft["b"] = 2 + draft["c"] = 3 + + # popitem should remove last inserted (LIFO) + key, value = draft.popitem() + assert key == "c" + assert value == 3 + + key, value = draft.popitem() + assert key == "b" + assert value == 2 + + result, _patches, _reverse = produce(base, recipe) + + assert result == {"a": 1} + + +def test_dict_get_none_implicit(): + """Test get() returns None implicitly when key missing.""" + base = {"a": 1} + + def recipe(draft): + value = draft.get("b") + assert value is None + + result, _patches, _reverse = produce(base, recipe) + + assert result == {"a": 1} + + +def test_dict_get_none_explicit(): + """Test get() with explicit None default.""" + base = {"a": 1} + + def recipe(draft): + value = draft.get("b", None) + assert value is None + + result, _patches, _reverse = produce(base, recipe) + + assert result == {"a": 1} diff --git a/tests/test_produce_list.py b/tests/test_produce_list.py new file mode 100644 index 0000000..6e455e9 --- /dev/null +++ b/tests/test_produce_list.py @@ -0,0 +1,984 @@ +"""Tests for ListProxy operations in produce().""" + +import pytest + +from patchdiff import produce +from patchdiff.pointer import Pointer + + +def test_list_append(): + """Test appending to a list.""" + base = [1, 2, 3] + + def recipe(draft): + draft.append(4) + + result, patches, reverse = produce(base, recipe) + + assert result == [1, 2, 3, 4] + assert len(patches) == 1 + assert patches[0] == {"op": "add", "path": Pointer(["-"]), "value": 4} + assert len(reverse) == 1 + # Reverse patch uses actual index (3) instead of "-" for correct application + assert reverse[0] == {"op": "remove", "path": Pointer([3])} + + +def test_list_insert(): + """Test inserting into a list.""" + base = [1, 2, 3] + + def recipe(draft): + draft.insert(1, 10) + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 10, 2, 3] + assert len(patches) == 1 + assert patches[0] == {"op": "add", "path": Pointer([1]), "value": 10} + + +def test_list_pop(): + """Test popping from a list.""" + base = [1, 2, 3] + + def recipe(draft): + value = draft.pop() + assert value == 3 + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 2] + assert len(patches) == 1 + assert patches[0]["op"] == "remove" + + +def test_list_remove(): + """Test removing from a list.""" + base = [1, 2, 3, 2] + + def recipe(draft): + draft.remove(2) # Removes first occurrence + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 3, 2] + assert len(patches) == 1 + + +def test_list_setitem(): + """Test setting an item in a list.""" + base = [1, 2, 3] + + def recipe(draft): + draft[1] = 20 + + result, patches, reverse = produce(base, recipe) + + assert result == [1, 20, 3] + assert len(patches) == 1 + assert patches[0] == {"op": "replace", "path": Pointer([1]), "value": 20} + assert reverse[0] == {"op": "replace", "path": Pointer([1]), "value": 2} + + +def test_list_delitem(): + """Test deleting an item from a list.""" + base = [1, 2, 3] + + def recipe(draft): + del draft[1] + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 3] + assert len(patches) == 1 + assert patches[0] == {"op": "remove", "path": Pointer([1])} + + +def test_list_extend(): + """Test extending a list.""" + base = [1, 2] + + def recipe(draft): + draft.extend([3, 4]) + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 2, 3, 4] + assert len(patches) == 2 # Two append operations + + +def test_list_clear(): + """Test clearing a list.""" + base = [1, 2, 3] + + def recipe(draft): + draft.clear() + + result, patches, _reverse = produce(base, recipe) + + assert result == [] + assert len(patches) == 3 # All elements removed + + +def test_list_nested(): + """Test operations on nested lists.""" + base = {"items": [1, 2, 3]} + + def recipe(draft): + draft["items"].append(4) + draft["items"][0] = 10 + + result, patches, _reverse = produce(base, recipe) + + assert result == {"items": [10, 2, 3, 4]} + assert len(patches) == 2 + + +def test_list_contains(): + """Test __contains__ (in operator) on list proxy.""" + base = [1, 2, 3] + + def recipe(draft): + assert 2 in draft + assert 5 not in draft + draft.append(5) + + result, _patches, _reverse = produce(base, recipe) + + assert 5 in result + + +def test_list_len(): + """Test __len__ on list proxy.""" + base = [1, 2, 3] + + def recipe(draft): + assert len(draft) == 3 + draft.append(4) + assert len(draft) == 4 + + result, _patches, _reverse = produce(base, recipe) + + assert len(result) == 4 + + +def test_list_pop_with_index(): + """Test pop() with specific index.""" + base = [1, 2, 3, 4] + + def recipe(draft): + value = draft.pop(1) + assert value == 2 + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 3, 4] + assert len(patches) == 1 + assert patches[0]["op"] == "remove" + + +def test_list_pop_negative_index(): + """Test pop() with negative index.""" + base = [1, 2, 3, 4] + + def recipe(draft): + value = draft.pop(-2) + assert value == 3 + + result, _patches, _reverse = produce(base, recipe) + + assert result == [1, 2, 4] + + +def test_list_getitem_slice(): + """Test __getitem__ with slice.""" + base = [1, 2, 3, 4, 5] + + def recipe(draft): + sliced = draft[1:3] + assert sliced == [2, 3] + + result, _patches, _reverse = produce(base, recipe) + + assert result == base + + +def test_list_setitem_slice(): + """Test __setitem__ with slice - same length replacement.""" + base = [1, 2, 3, 4, 5] + + def recipe(draft): + draft[1:3] = [20, 30] + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 20, 30, 4, 5] + # Should generate replace patches for indices 1 and 2 + assert len(patches) == 2 + assert patches[0]["op"] == "replace" + assert patches[0]["path"].tokens == (1,) + assert patches[0]["value"] == 20 + assert patches[1]["op"] == "replace" + assert patches[1]["path"].tokens == (2,) + assert patches[1]["value"] == 30 + + +def test_list_setitem_slice_expand(): + """Test __setitem__ with slice - expanding (adding elements).""" + base = [1, 2, 3, 4, 5] + + def recipe(draft): + draft[1:3] = [20, 30, 40] + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 20, 30, 40, 4, 5] + # Should replace 2 elements and add 1 + assert len(patches) == 3 + assert patches[0]["op"] == "replace" # Replace index 1 + assert patches[1]["op"] == "replace" # Replace index 2 + assert patches[2]["op"] == "add" # Add at index 3 + + +def test_list_setitem_slice_shrink(): + """Test __setitem__ with slice - shrinking (removing elements).""" + base = [1, 2, 3, 4, 5] + + def recipe(draft): + draft[1:4] = [20] + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 20, 5] + # Should replace 1 element and remove 2 + assert len(patches) == 3 + assert patches[0]["op"] == "replace" # Replace index 1 + assert patches[1]["op"] == "remove" # Remove index 3 + assert patches[2]["op"] == "remove" # Remove index 2 + + +def test_list_setitem_slice_step(): + """Test __setitem__ with step slice.""" + base = [1, 2, 3, 4, 5] + + def recipe(draft): + draft[::2] = [10, 30, 50] # Replace indices 0, 2, 4 + + result, patches, _reverse = produce(base, recipe) + + assert result == [10, 2, 30, 4, 50] + # Should generate replace patches for indices 0, 2, 4 + assert len(patches) == 3 + assert all(p["op"] == "replace" for p in patches) + + +def test_list_delitem_slice(): + """Test __delitem__ with slice.""" + base = [1, 2, 3, 4, 5] + + def recipe(draft): + del draft[1:3] + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 4, 5] + # Should generate remove patches (in reverse order to maintain indices) + assert len(patches) == 2 + assert patches[0]["op"] == "remove" + assert patches[0]["path"].tokens == (2,) # Remove 3 first + assert patches[1]["op"] == "remove" + assert patches[1]["path"].tokens == (1,) # Then remove 2 + + +def test_list_delitem_slice_step(): + """Test __delitem__ with step slice.""" + base = [1, 2, 3, 4, 5] + + def recipe(draft): + del draft[::2] # Delete indices 0, 2, 4 + + result, patches, _reverse = produce(base, recipe) + + assert result == [2, 4] + # Should generate remove patches for indices 0, 2, 4 (in reverse) + assert len(patches) == 3 + assert all(p["op"] == "remove" for p in patches) + + +def test_list_index(): + """Test index() method on list proxy.""" + base = [1, 2, 3, 2, 4] + + def recipe(draft): + idx = draft.index(2) + assert idx == 1 + + result, _patches, _reverse = produce(base, recipe) + + assert result == base + + +def test_list_index_not_found(): + """Test index() with value not in list.""" + base = [1, 2, 3] + + def recipe(draft): + draft.index(5) + + with pytest.raises(ValueError): + produce(base, recipe) + + +def test_list_count(): + """Test count() method on list proxy.""" + base = [1, 2, 3, 2, 4, 2] + + def recipe(draft): + count = draft.count(2) + assert count == 3 + draft.append(2) + + result, _patches, _reverse = produce(base, recipe) + + assert result.count(2) == 4 + + +def test_list_reverse(): + """Test reverse() method on list proxy.""" + base = [1, 2, 3, 4] + + def recipe(draft): + draft.reverse() + + result, _patches, _reverse = produce(base, recipe) + + assert result == [4, 3, 2, 1] + + +def test_list_sort(): + """Test sort() method on list proxy.""" + base = [3, 1, 4, 2] + + def recipe(draft): + draft.sort() + + result, _patches, _reverse = produce(base, recipe) + + assert result == [1, 2, 3, 4] + + +def test_list_sort_reverse(): + """Test sort() with reverse parameter.""" + base = [3, 1, 4, 2] + + def recipe(draft): + draft.sort(reverse=True) + + result, _patches, _reverse = produce(base, recipe) + + assert result == [4, 3, 2, 1] + + +def test_list_sort_with_key(): + """Test sort() with key function.""" + base = ["apple", "pie", "a", "cherry"] + + def recipe(draft): + draft.sort(key=len) + + result, _patches, _reverse = produce(base, recipe) + + assert result == ["a", "pie", "apple", "cherry"] + + +def test_list_reversed(): + """Test __reversed__ on list proxy.""" + base = [1, 2, 3, 4] + + def recipe(draft): + rev = list(reversed(draft)) + # Verify reversed returns elements in reverse order + assert rev == [4, 3, 2, 1] + + result, patches, _reverse = produce(base, recipe) + + # No mutations, so no patches + assert patches == [] + assert result == base + + +def test_list_copy(): + """Test copy() method on list proxy.""" + base = [1, 2, 3] + + def recipe(draft): + copied = draft.copy() + # Verify copy returns a real list with same contents + assert copied == [1, 2, 3] + assert isinstance(copied, list) + # Verify it's a different object (not a proxy) + copied.append(4) + # This mutation is on the copy, not the draft + + result, patches, _reverse = produce(base, recipe) + + # No mutations to draft, so no patches + assert patches == [] + assert result == base + + +def test_list_negative_index_nested_mutation(): + """Test that negative indices generate correct paths for nested mutations. + + This tests a bug where accessing nested structures via negative indices + would cache the proxy with the negative index and generate incorrect paths. + For example, draft[-1]["name"] = "new" on a 3-element list should generate + a path of [2, "name"], not [-1, "name"]. + """ + base = [{"name": "first"}, {"name": "second"}, {"name": "third"}] + + def recipe(draft): + # Access via negative index and mutate nested structure + draft[-1]["name"] = "THIRD" + draft[-2]["name"] = "SECOND" + + result, patches, _reverse = produce(base, recipe) + + assert result == [{"name": "first"}, {"name": "SECOND"}, {"name": "THIRD"}] + assert len(patches) == 2 + + # Verify paths use positive indices, not negative + # draft[-1] on a 3-element list should resolve to index 2 + # draft[-2] on a 3-element list should resolve to index 1 + patch_paths = [tuple(p["path"].tokens) for p in patches] + assert (2, "name") in patch_paths, f"Expected (2, 'name') in {patch_paths}" + assert (1, "name") in patch_paths, f"Expected (1, 'name') in {patch_paths}" + + # Verify no negative indices in paths + for patch in patches: + for token in patch["path"].tokens: + if isinstance(token, int): + assert token >= 0, ( + f"Found negative index {token} in path {patch['path']}" + ) + + +def test_list_negative_index_cache_consistency(): + """Test that accessing same element via positive and negative index returns same proxy. + + If we access draft[2] and draft[-1] on a 3-element list, both should + refer to the same underlying element and mutations should be consistent. + """ + base = [{"a": 1}, {"b": 2}, {"c": 3}] + + def recipe(draft): + # Access via negative index first + draft[-1]["c"] = 30 + # Access via positive index - should see the mutation + assert draft[2]["c"] == 30 + # Mutate via positive index + draft[2]["d"] = 4 + # Verify via negative index + assert draft[-1]["d"] == 4 + + result, patches, _reverse = produce(base, recipe) + + assert result[2] == {"c": 30, "d": 4} + # All paths should use positive indices + for patch in patches: + for token in patch["path"].tokens: + if isinstance(token, int): + assert token >= 0, ( + f"Found negative index {token} in path {patch['path']}" + ) + + +def test_list_slice_returns_wrapped_nested_structures(): + """Test that slicing a list returns wrapped proxies for nested structures. + + When accessing a slice of a list containing nested dicts/lists/sets, + mutations to those nested structures should be tracked and generate patches. + """ + base = [{"a": 1}, {"b": 2}, {"c": 3}, {"d": 4}] + + def recipe(draft): + # Get a slice of the list + sliced = draft[1:3] + # sliced should be [{"b": 2}, {"c": 3}] + # Mutate a nested structure obtained from the slice + sliced[0]["b"] = 20 + sliced[1]["c"] = 30 + + result, patches, _reverse = produce(base, recipe) + + # Verify the mutations took effect + assert result == [{"a": 1}, {"b": 20}, {"c": 30}, {"d": 4}] + + # Verify patches were generated for the nested mutations + assert len(patches) == 2 + patch_paths = [tuple(p["path"].tokens) for p in patches] + assert (1, "b") in patch_paths, f"Expected (1, 'b') in {patch_paths}" + assert (2, "c") in patch_paths, f"Expected (2, 'c') in {patch_paths}" + + +def test_list_slice_nested_list_mutation(): + """Test that slicing works with nested lists.""" + base = [[1, 2], [3, 4], [5, 6]] + + def recipe(draft): + sliced = draft[0:2] + sliced[0].append(99) + sliced[1][0] = 30 + + result, patches, _reverse = produce(base, recipe) + + assert result == [[1, 2, 99], [30, 4], [5, 6]] + assert len(patches) == 2 + + +def test_list_slice_with_step_nested_mutation(): + """Test that slicing with step returns wrapped nested structures.""" + base = [{"a": 1}, {"b": 2}, {"c": 3}, {"d": 4}, {"e": 5}] + + def recipe(draft): + # Get every other element: indices 0, 2, 4 + sliced = draft[::2] + sliced[0]["a"] = 10 # Mutate index 0 + sliced[2]["e"] = 50 # Mutate index 4 + + result, patches, _reverse = produce(base, recipe) + + assert result == [{"a": 10}, {"b": 2}, {"c": 3}, {"d": 4}, {"e": 50}] + assert len(patches) == 2 + patch_paths = [tuple(p["path"].tokens) for p in patches] + assert (0, "a") in patch_paths + assert (4, "e") in patch_paths + + +def test_list_setitem_negative_index(): + """Test that __setitem__ with negative index generates correct path. + + Setting draft[-1] = value on a 3-element list should generate a patch + with path [2], not [-1]. + """ + base = [1, 2, 3] + + def recipe(draft): + draft[-1] = 30 + draft[-2] = 20 + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 20, 30] + assert len(patches) == 2 + + # Verify paths use positive indices + patch_paths = [tuple(p["path"].tokens) for p in patches] + assert (2,) in patch_paths, f"Expected (2,) in {patch_paths}" + assert (1,) in patch_paths, f"Expected (1,) in {patch_paths}" + + # Verify no negative indices in paths + for patch in patches: + for token in patch["path"].tokens: + if isinstance(token, int): + assert token >= 0, ( + f"Found negative index {token} in path {patch['path']}" + ) + + +def test_list_delitem_negative_index(): + """Test that __delitem__ with negative index generates correct path. + + Deleting draft[-1] on a 3-element list should generate a patch + with path [2], not [-1]. + """ + base = [1, 2, 3, 4] + + def recipe(draft): + del draft[-1] # Delete 4, which is at index 3 + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 2, 3] + assert len(patches) == 1 + assert patches[0]["op"] == "remove" + + # Verify path uses positive index (3, not -1) + assert patches[0]["path"].tokens == (3,), ( + f"Expected (3,), got {patches[0]['path'].tokens}" + ) + + +def test_list_delitem_negative_index_multiple(): + """Test multiple deletions with negative indices.""" + base = [1, 2, 3, 4, 5] + + def recipe(draft): + del draft[-1] # Delete 5 at index 4 + del draft[-2] # Delete 3 at index 2 (list is now [1,2,3,4]) + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 2, 4] + assert len(patches) == 2 + + # First deletion should be at index 4 + assert patches[0]["path"].tokens == (4,) + # Second deletion should be at index 2 (after first deletion, list is [1,2,3,4]) + assert patches[1]["path"].tokens == (2,) + + +def test_list_setitem_negative_index_nested(): + """Test that __setitem__ with negative index works for nested structure replacement.""" + base = [{"a": 1}, {"b": 2}, {"c": 3}] + + def recipe(draft): + draft[-1] = {"c": 30} + + result, patches, _reverse = produce(base, recipe) + + assert result == [{"a": 1}, {"b": 2}, {"c": 30}] + assert len(patches) == 1 + assert patches[0]["path"].tokens == (2,) + assert patches[0]["op"] == "replace" + + +def test_list_directly_containing_sets(): + """Test that sets directly inside lists are properly wrapped and tracked.""" + base = [{1, 2, 3}, {4, 5, 6}] + + def recipe(draft): + # Access the sets directly in the list and mutate them + draft[0].add(10) + draft[1].remove(5) + + result, patches, _reverse = produce(base, recipe) + + assert result[0] == {1, 2, 3, 10} + assert result[1] == {4, 6} + assert len(patches) == 2 + + +def test_list_containing_set_nested_mutation(): + """Test that sets nested inside lists are properly wrapped and tracked.""" + base = [{"tags": {1, 2, 3}}, {"tags": {4, 5}}] + + def recipe(draft): + # Access the set inside the list and mutate it + draft[0]["tags"].add(10) + draft[1]["tags"].remove(4) + + result, patches, _reverse = produce(base, recipe) + + assert result[0]["tags"] == {1, 2, 3, 10} + assert result[1]["tags"] == {4, 5} - {4} + assert len(patches) == 2 + + +def test_list_setitem_invalidates_proxy_cache(): + """Test that __setitem__ invalidates the proxy cache for nested structures.""" + base = [{"a": 1}, {"b": 2}] + + def recipe(draft): + # Access nested to create a proxy cache entry + _ = draft[0]["a"] + # Replace the nested structure entirely + draft[0] = {"c": 3} + # Access again - should get new structure, not cached proxy + assert dict(draft[0]) == {"c": 3} + + result, _patches, _reverse = produce(base, recipe) + + assert result == [{"c": 3}, {"b": 2}] + + +def test_list_setitem_slice_step_length_mismatch(): + """Test that step slice assignment raises ValueError on length mismatch.""" + base = [1, 2, 3, 4, 5] + + def recipe(draft): + # Try to assign 2 values to 3 positions (indices 0, 2, 4) + draft[::2] = [10, 20] # This should fail + + with pytest.raises(ValueError, match="attempt to assign sequence of size 2"): + produce(base, recipe) + + +def test_list_insert_at_beginning(): + """Test insert() at index 0 (beginning).""" + base = [1, 2, 3] + + def recipe(draft): + draft.insert(0, 0) + + result, patches, _reverse = produce(base, recipe) + + assert result == [0, 1, 2, 3] + assert len(patches) == 1 + assert patches[0]["op"] == "add" + assert patches[0]["path"].tokens == (0,) + + +def test_list_insert_beyond_length(): + """Test insert() with index > len (appends to end).""" + base = [1, 2, 3] + + def recipe(draft): + draft.insert(100, 4) + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 2, 3, 4] + # Should add at the end + assert len(patches) == 1 + + +def test_list_insert_negative_index(): + """Test insert() with negative index.""" + base = [1, 2, 3] + + def recipe(draft): + draft.insert(-1, 99) # Insert before last element + + result, _patches, _reverse = produce(base, recipe) + + assert result == [1, 2, 99, 3] + + +def test_list_insert_large_negative_index(): + """Test insert() with large negative index (inserts at beginning).""" + base = [1, 2, 3] + + def recipe(draft): + draft.insert(-100, 0) + + result, _patches, _reverse = produce(base, recipe) + + assert result == [0, 1, 2, 3] + + +def test_list_pop_empty(): + """Test pop() on empty list raises IndexError.""" + base = [] + + def recipe(draft): + draft.pop() + + with pytest.raises(IndexError, match="list index out of range"): + produce(base, recipe) + + +def test_list_pop_out_of_range(): + """Test pop() with out-of-range index raises IndexError.""" + base = [1, 2, 3] + + def recipe(draft): + draft.pop(10) + + with pytest.raises(IndexError, match="list index out of range"): + produce(base, recipe) + + +def test_list_remove_not_found(): + """Test remove() with element not in list raises ValueError.""" + base = [1, 2, 3] + + def recipe(draft): + draft.remove(99) + + with pytest.raises(ValueError, match="not in list"): + produce(base, recipe) + + +def test_list_remove_first_occurrence(): + """Test remove() removes only first occurrence.""" + base = [1, 2, 3, 2, 4] + + def recipe(draft): + draft.remove(2) + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 3, 2, 4] # First 2 removed + assert len(patches) == 1 + + +def test_list_extend_with_tuple(): + """Test extend() with tuple.""" + base = [1, 2] + + def recipe(draft): + draft.extend((3, 4, 5)) + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 2, 3, 4, 5] + assert len(patches) == 3 + + +def test_list_extend_with_empty(): + """Test extend() with empty iterable (no-op).""" + base = [1, 2, 3] + + def recipe(draft): + draft.extend([]) + + result, patches, _reverse = produce(base, recipe) + + assert result == [1, 2, 3] + assert len(patches) == 0 + + +def test_list_clear_empty(): + """Test clear() on already empty list.""" + base = [] + + def recipe(draft): + draft.clear() + + result, patches, _reverse = produce(base, recipe) + + assert result == [] + assert len(patches) == 0 + + +def test_list_reverse_empty(): + """Test reverse() on empty list.""" + base = [] + + def recipe(draft): + draft.reverse() + + result, patches, _reverse = produce(base, recipe) + + assert result == [] + assert len(patches) == 0 + + +def test_list_reverse_single_element(): + """Test reverse() on single-element list.""" + base = [1] + + def recipe(draft): + draft.reverse() + + result, patches, _reverse = produce(base, recipe) + + assert result == [1] + assert len(patches) == 0 + + +def test_list_sort_empty(): + """Test sort() on empty list.""" + base = [] + + def recipe(draft): + draft.sort() + + result, patches, _reverse = produce(base, recipe) + + assert result == [] + assert len(patches) == 0 + + +def test_list_sort_single_element(): + """Test sort() on single-element list.""" + base = [1] + + def recipe(draft): + draft.sort() + + result, patches, _reverse = produce(base, recipe) + + assert result == [1] + assert len(patches) == 0 + + +def test_list_sort_with_key_and_reverse(): + """Test sort() with both key and reverse parameters.""" + base = ["apple", "pie", "a", "longer"] + + def recipe(draft): + draft.sort(key=len, reverse=True) + + result, patches, _reverse = produce(base, recipe) + + assert result == ["longer", "apple", "pie", "a"] + # Should have patches for changed positions + assert len(patches) > 0 + + +def test_list_index_with_start(): + """Test index() with start parameter.""" + base = [1, 2, 3, 2, 4] + + def recipe(draft): + # Find first 2 + idx1 = draft.index(2) + assert idx1 == 1 + + # Find second 2 by starting search after first + idx2 = draft.index(2, 2) + assert idx2 == 3 + + result, _patches, _reverse = produce(base, recipe) + + assert result == base + + +def test_list_index_with_start_and_end(): + """Test index() with start and end parameters.""" + base = [1, 2, 3, 4, 5, 2, 6] + + def recipe(draft): + # Search for 2 in slice [0:4] + idx1 = draft.index(2, 0, 4) + assert idx1 == 1 + + # Search for 2 in slice [4:] - should find the second 2 + idx2 = draft.index(2, 4) + assert idx2 == 5 + + result, _patches, _reverse = produce(base, recipe) + + assert result == base + + +def test_list_index_with_negative_indices(): + """Test index() with negative start/end indices.""" + base = [1, 2, 3, 4, 5] + + def recipe(draft): + # Search last 3 elements + idx = draft.index(4, -3) + assert idx == 3 + + result, _patches, _reverse = produce(base, recipe) + + assert result == base + + +def test_list_count_not_in_list(): + """Test count() with element not in list returns 0.""" + base = [1, 2, 3] + + def recipe(draft): + c = draft.count(99) + assert c == 0 + + result, _patches, _reverse = produce(base, recipe) + + assert result == base + + +def test_list_count_empty_list(): + """Test count() on empty list returns 0.""" + base = [] + + def recipe(draft): + c = draft.count(1) + assert c == 0 + + result, _patches, _reverse = produce(base, recipe) + + assert result == [] diff --git a/tests/test_produce_set.py b/tests/test_produce_set.py new file mode 100644 index 0000000..dda53b6 --- /dev/null +++ b/tests/test_produce_set.py @@ -0,0 +1,537 @@ +"""Tests for SetProxy operations in produce().""" + +import pytest + +from patchdiff import produce +from patchdiff.pointer import Pointer + + +def test_set_add(): + """Test adding to a set.""" + base = {1, 2, 3} + + def recipe(draft): + draft.add(4) + + result, patches, _reverse = produce(base, recipe) + + assert result == {1, 2, 3, 4} + assert len(patches) == 1 + assert patches[0] == {"op": "add", "path": Pointer(["-"]), "value": 4} + + +def test_set_remove(): + """Test removing from a set.""" + base = {1, 2, 3} + + def recipe(draft): + draft.remove(2) + + result, patches, reverse = produce(base, recipe) + + assert result == {1, 3} + assert len(patches) == 1 + assert patches[0] == {"op": "remove", "path": Pointer([2])} + assert reverse[0] == {"op": "add", "path": Pointer([2]), "value": 2} + + +def test_set_discard(): + """Test discarding from a set.""" + base = {1, 2, 3} + + def recipe(draft): + draft.discard(2) + draft.discard(10) # Doesn't raise error + + result, patches, _reverse = produce(base, recipe) + + assert result == {1, 3} + assert len(patches) == 1 # Only removal of 2 + + +def test_set_update(): + """Test updating a set.""" + base = {1, 2} + + def recipe(draft): + draft.update({3, 4}) + + result, patches, _reverse = produce(base, recipe) + + assert result == {1, 2, 3, 4} + assert len(patches) == 2 + + +def test_set_clear(): + """Test clearing a set.""" + base = {1, 2, 3} + + def recipe(draft): + draft.clear() + + result, patches, _reverse = produce(base, recipe) + + assert result == set() + assert len(patches) == 3 + + +def test_set_contains(): + """Test __contains__ (in operator) on set proxy.""" + base = {1, 2, 3} + + def recipe(draft): + assert 2 in draft + assert 5 not in draft + draft.add(5) + + result, _patches, _reverse = produce(base, recipe) + + assert 5 in result + + +def test_set_len(): + """Test __len__ on set proxy.""" + base = {1, 2, 3} + + def recipe(draft): + assert len(draft) == 3 + draft.add(4) + assert len(draft) == 4 + + result, _patches, _reverse = produce(base, recipe) + + assert len(result) == 4 + + +def test_set_pop_non_empty(): + """Test pop() on non-empty set.""" + base = {1, 2, 3} + + def recipe(draft): + value = draft.pop() + assert value in {1, 2, 3} + + result, patches, _reverse = produce(base, recipe) + + assert len(result) == 2 + assert len(patches) == 1 + assert patches[0]["op"] == "remove" + + +def test_set_pop_empty(): + """Test pop() on empty set raises KeyError.""" + base = set() + + def recipe(draft): + draft.pop() + + with pytest.raises(KeyError): + produce(base, recipe) + + +def test_set_remove_not_found(): + """Test remove() with value not in set raises KeyError.""" + base = {1, 2, 3} + + def recipe(draft): + draft.remove(5) + + with pytest.raises(KeyError): + produce(base, recipe) + + +def test_set_union(): + """Test union() method on set proxy.""" + base = {1, 2, 3} + + def recipe(draft): + result = draft.union({3, 4, 5}) + assert result == {1, 2, 3, 4, 5} + + result, _patches, _reverse = produce(base, recipe) + + assert result == {1, 2, 3} # Original unchanged by union + + +def test_set_intersection(): + """Test intersection() method on set proxy.""" + base = {1, 2, 3} + + def recipe(draft): + result = draft.intersection({2, 3, 4}) + assert result == {2, 3} + + result, _patches, _reverse = produce(base, recipe) + + assert result == {1, 2, 3} # Original unchanged + + +def test_set_difference(): + """Test difference() method on set proxy.""" + base = {1, 2, 3} + + def recipe(draft): + result = draft.difference({2, 4}) + assert result == {1, 3} + + result, _patches, _reverse = produce(base, recipe) + + assert result == {1, 2, 3} # Original unchanged + + +def test_set_symmetric_difference(): + """Test symmetric_difference() method on set proxy.""" + base = {1, 2, 3} + + def recipe(draft): + result = draft.symmetric_difference({2, 3, 4}) + assert result == {1, 4} + + result, _patches, _reverse = produce(base, recipe) + + assert result == {1, 2, 3} # Original unchanged + + +def test_set_update_inplace_operator(): + """Test |= operator on set proxy.""" + base = {1, 2, 3} + + def recipe(draft): + draft |= {3, 4, 5} + + result, patches, _reverse = produce(base, recipe) + + assert result == {1, 2, 3, 4, 5} + assert len(patches) == 2 # Added 4 and 5 + + +def test_set_intersection_update(): + """Test &= operator on set proxy.""" + base = {1, 2, 3} + + def recipe(draft): + draft &= {2, 3, 4} + + result, patches, _reverse = produce(base, recipe) + + assert result == {2, 3} + assert len(patches) == 1 # Removed 1 + + +def test_set_difference_update(): + """Test -= operator on set proxy.""" + base = {1, 2, 3} + + def recipe(draft): + draft -= {2, 4} + + result, patches, _reverse = produce(base, recipe) + + assert result == {1, 3} + assert len(patches) == 1 # Removed 2 + + +def test_set_symmetric_difference_update(): + """Test ^= operator on set proxy.""" + base = {1, 2, 3} + + def recipe(draft): + draft ^= {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_iter(): + """Test iterating over set.""" + base = {1, 2, 3} + + def recipe(draft): + values = [] + for value in draft: + values.append(value) + assert set(values) == {1, 2, 3} + + result, _patches, _reverse = produce(base, recipe) + + assert result == base + + +def test_set_isdisjoint(): + """Test isdisjoint() method on set proxy.""" + base = {1, 2, 3} + + def recipe(draft): + # Verify isdisjoint works correctly + assert draft.isdisjoint({4, 5, 6}) is True + assert draft.isdisjoint({2, 4}) is False + + result, patches, _reverse = produce(base, recipe) + + # No mutations, so no patches + assert patches == [] + assert result == base + + +def test_set_issubset(): + """Test issubset() method on set proxy.""" + base = {1, 2} + + def recipe(draft): + # Verify issubset works correctly + assert draft.issubset({1, 2, 3}) is True + assert draft.issubset({1}) is False + + result, patches, _reverse = produce(base, recipe) + + # No mutations, so no patches + assert patches == [] + assert result == base + + +def test_set_issuperset(): + """Test issuperset() method on set proxy.""" + base = {1, 2, 3} + + def recipe(draft): + # Verify issuperset works correctly + assert draft.issuperset({1, 2}) is True + assert draft.issuperset({1, 2, 4}) is False + + result, patches, _reverse = produce(base, recipe) + + # No mutations, so no patches + assert patches == [] + assert result == base + + +def test_set_copy(): + """Test copy() method on set proxy.""" + base = {1, 2, 3} + + def recipe(draft): + copied = draft.copy() + # Verify copy returns a real set with same contents + assert copied == {1, 2, 3} + assert isinstance(copied, set) + # Verify it's a different object (not a proxy) + copied.add(4) + # This mutation is on the copy, not the draft + + result, patches, _reverse = produce(base, recipe) + + # No mutations to draft, so no patches + assert patches == [] + assert result == base + + +def test_set_add_existing(): + """Test add() with existing element (no-op).""" + base = {1, 2, 3} + + def recipe(draft): + draft.add(2) # Already exists + + result, patches, _reverse = produce(base, recipe) + + assert result == {1, 2, 3} + assert len(patches) == 0 # No change + + +def test_set_update_no_args(): + """Test update() with no arguments (no-op).""" + base = {1, 2, 3} + + def recipe(draft): + draft.update() + + result, patches, _reverse = produce(base, recipe) + + assert result == {1, 2, 3} + assert len(patches) == 0 + + +def test_set_update_multiple_iterables(): + """Test update() with multiple iterables.""" + base = {1, 2} + + def recipe(draft): + draft.update({3, 4}, [5, 6], (7, 8)) + + result, patches, _reverse = produce(base, recipe) + + assert result == {1, 2, 3, 4, 5, 6, 7, 8} + assert len(patches) == 6 # Added 6 new elements + + +def test_set_update_with_empty(): + """Test update() with empty iterable (no-op).""" + base = {1, 2, 3} + + def recipe(draft): + draft.update([]) + + result, patches, _reverse = produce(base, recipe) + + assert result == {1, 2, 3} + assert len(patches) == 0 + + +def test_set_clear_empty(): + """Test clear() on already empty set.""" + base = set() + + def recipe(draft): + draft.clear() + + result, patches, _reverse = produce(base, recipe) + + assert result == set() + assert len(patches) == 0 + + +def test_set_union_no_args(): + """Test union() with no arguments (returns copy).""" + base = {1, 2, 3} + + def recipe(draft): + result_set = draft.union() + assert result_set == {1, 2, 3} + assert isinstance(result_set, set) + + result, _patches, _reverse = produce(base, recipe) + + assert result == base + + +def test_set_union_multiple_iterables(): + """Test union() with multiple iterables.""" + base = {1, 2} + + def recipe(draft): + result_set = draft.union({3, 4}, [5, 6]) + assert result_set == {1, 2, 3, 4, 5, 6} + + result, _patches, _reverse = produce(base, recipe) + + assert result == base # union doesn't mutate + + +def test_set_intersection_no_args(): + """Test intersection() with no arguments (returns copy).""" + base = {1, 2, 3} + + def recipe(draft): + result_set = draft.intersection() + assert result_set == {1, 2, 3} + + result, _patches, _reverse = produce(base, recipe) + + assert result == base + + +def test_set_intersection_multiple_iterables(): + """Test intersection() with multiple iterables.""" + base = {1, 2, 3, 4, 5} + + def recipe(draft): + result_set = draft.intersection({2, 3, 4, 6}, [3, 4, 5, 7]) + assert result_set == {3, 4} # Common to all + + result, _patches, _reverse = produce(base, recipe) + + assert result == base + + +def test_set_difference_no_args(): + """Test difference() with no arguments (returns copy).""" + base = {1, 2, 3} + + def recipe(draft): + result_set = draft.difference() + assert result_set == {1, 2, 3} + + result, _patches, _reverse = produce(base, recipe) + + assert result == base + + +def test_set_difference_multiple_iterables(): + """Test difference() with multiple iterables.""" + base = {1, 2, 3, 4, 5} + + def recipe(draft): + result_set = draft.difference({2, 3}, [4]) + assert result_set == {1, 5} + + result, _patches, _reverse = produce(base, recipe) + + assert result == base + + +def test_set_isdisjoint_with_empty(): + """Test isdisjoint() with empty set.""" + base = {1, 2, 3} + + def recipe(draft): + # Empty sets are disjoint with all sets + assert draft.isdisjoint(set()) is True + assert draft.isdisjoint([]) is True + + result, _patches, _reverse = produce(base, recipe) + + assert result == base + + +def test_set_issubset_empty(): + """Test that empty set is subset of all sets.""" + base = set() + + def recipe(draft): + # Empty set is subset of any set + assert draft.issubset({1, 2, 3}) is True + assert draft.issubset(set()) is True + + result, _patches, _reverse = produce(base, recipe) + + assert result == set() + + +def test_set_issubset_self(): + """Test that set is subset of itself.""" + base = {1, 2, 3} + + def recipe(draft): + assert draft.issubset({1, 2, 3}) is True + + result, _patches, _reverse = produce(base, recipe) + + assert result == base + + +def test_set_issuperset_empty(): + """Test that all sets are superset of empty set.""" + base = {1, 2, 3} + + def recipe(draft): + # All sets are superset of empty set + assert draft.issuperset(set()) is True + assert draft.issuperset([]) is True + + result, _patches, _reverse = produce(base, recipe) + + assert result == base + + +def test_set_issuperset_self(): + """Test that set is superset of itself.""" + base = {1, 2, 3} + + def recipe(draft): + assert draft.issuperset({1, 2, 3}) is True + + result, _patches, _reverse = produce(base, recipe) + + assert result == base