diff --git a/patchdiff/produce.py b/patchdiff/produce.py index d722e55..0b65802 100644 --- a/patchdiff/produce.py +++ b/patchdiff/produce.py @@ -296,6 +296,11 @@ def _location(self) -> tuple | None: def _wrap(self, key: Hashable, value: Any) -> Any: """Wrap nested structures in proxies using duck typing.""" + # Scalars never need wrapping and never have a cached child proxy + # (cache entries only exist for container values), so skip both + # the cache lookup and the hasattr chain for them + if value.__class__ in _SCALAR_TYPES: + return value # Check cache first - it's faster than hasattr() calls proxies = self._proxies if proxies is not None and key in proxies: @@ -383,6 +388,8 @@ class DictProxy(_Proxy): def __getitem__(self, key: Any) -> Any: value = self._data[key] + if value.__class__ in _SCALAR_TYPES: + return value return self._wrap(key, value) def __setitem__(self, key: Any, value: Any) -> None: @@ -392,14 +399,17 @@ def __setitem__(self, key: Any, value: Any) -> None: self._detach(key) if value.__class__ not in _SCALAR_TYPES: value = self._adopt(key, value) + data = self._data tokens = self._location() if tokens is not None: path = Pointer((*tokens, key)) - if key in self._data: - self._recorder.record_replace(path, self._data[key], value) + # One lookup instead of a containment check plus a read + old_value = data.get(key, _MISSING) + if old_value is not _MISSING: + self._recorder.record_replace(path, old_value, value) else: self._recorder.record_add(path, value) - self._data[key] = value + data[key] = value def __delitem__(self, key: Any) -> None: old_value = self._data[key] @@ -470,13 +480,17 @@ def popitem(self): def values(self): """Return proxied values so nested mutations are tracked.""" - for key in self._data: - yield self._wrap(key, self._data[key]) + scalar_types = _SCALAR_TYPES + wrap = self._wrap + for key, value in self._data.items(): + yield value if value.__class__ in scalar_types else wrap(key, value) def items(self): """Return (key, proxied_value) pairs so nested mutations are tracked.""" - for key in self._data: - yield key, self._wrap(key, self._data[key]) + scalar_types = _SCALAR_TYPES + wrap = self._wrap + for key, value in self._data.items(): + yield key, (value if value.__class__ in scalar_types else wrap(key, value)) def __ior__(self, other): """Implement |= operator (merge update).""" @@ -535,9 +549,12 @@ def __getitem__(self, index: 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] + data = self._data + wrap = self._wrap + start, stop, step = index.indices(len(data)) + return [wrap(i, data[i]) for i in range(start, stop, step)] + if value.__class__ in _SCALAR_TYPES: + return value # Resolve negative indices to positive for consistent caching and paths if index < 0: index = len(self._data) + index @@ -676,16 +693,17 @@ def __delitem__(self, index: int | slice) -> None: self._shift_cache(index + 1, -1) def append(self, value: Any) -> None: + data = self._data if value.__class__ not in _SCALAR_TYPES: - value = self._adopt(len(self._data), value) + value = self._adopt(len(data), value) tokens = self._location() if tokens is not None: # Forward patch uses "-" (append to end), reverse patch uses # the actual index - forward_path = Pointer((*tokens, "-")) - reverse_path = Pointer((*tokens, len(self._data))) - self._recorder.record_add(forward_path, value, reverse_path) - self._data.append(value) + self._recorder.record_add( + Pointer((*tokens, "-")), value, Pointer((*tokens, len(data))) + ) + data.append(value) def insert(self, index: int, value: Any) -> None: # Normalize the index the same way list.insert does (clamped), so @@ -814,13 +832,19 @@ def sort(self, *args, **kwargs) -> None: def __iter__(self): """Iterate over list elements, wrapping nested structures in proxies.""" - for i in range(len(self._data)): - yield self._wrap(i, self._data[i]) + scalar_types = _SCALAR_TYPES + wrap = self._wrap + for i, value in enumerate(self._data): + yield value if value.__class__ in scalar_types else wrap(i, value) def __reversed__(self): """Iterate in reverse, wrapping nested structures in proxies.""" - for i in range(len(self._data) - 1, -1, -1): - yield self._wrap(i, self._data[i]) + scalar_types = _SCALAR_TYPES + wrap = self._wrap + data = self._data + for i in range(len(data) - 1, -1, -1): + value = data[i] + yield value if value.__class__ in scalar_types else wrap(i, value) def __iadd__(self, other): """Implement += operator (in-place extend).""" diff --git a/tests/test_produce_core.py b/tests/test_produce_core.py index 12220b5..e013b89 100644 --- a/tests/test_produce_core.py +++ b/tests/test_produce_core.py @@ -3,6 +3,7 @@ import pytest from patchdiff import apply, produce +from patchdiff.pointer import Pointer from patchdiff.produce import DictProxy, ListProxy, SetProxy @@ -973,3 +974,20 @@ def test_snapshot_unwraps_proxy_passed_directly(): assert type(snap) is dict assert snap == {"x": [1]} assert snap["x"] is not proxy._data["x"] + + +def test_reading_scalars_and_atomics_from_draft(): + """Reads of scalar and atomic (tuple/frozenset) values return them + unwrapped, via the proxies' scalar fast paths.""" + base = {"n": 1, "t": (1, [2]), "items": [10, "x", (3, 4)]} + + def recipe(draft): + assert draft["n"] == 1 + assert draft["t"] == (1, [2]) # non-scalar atomic through _wrap + assert draft["items"][0] == 10 # scalar by list index + assert draft["items"][2] == (3, 4) + draft["n"] = draft["items"][0] + 1 + + result, patches, _reverse_patches = produce(base, recipe) + assert result["n"] == 11 + assert patches == [{"op": "replace", "path": Pointer(["n"]), "value": 11}]