Skip to content

Proxy-Based Patch Generation

Diffing compares two complete states after the fact. When your own code is the thing making the changes, produce can skip the comparison entirely: it hands your recipe a proxy-wrapped draft, records every mutation as it happens, and emits the patches directly. The idea (and the name) come from Immer.

from patchdiff import produce

base = {"count": 0, "items": [1, 2, 3]}

def recipe(draft):
    draft["count"] = 5
    draft["items"].append(4)

result, patches, reverse_patches = produce(base, recipe)

assert base == {"count": 0, "items": [1, 2, 3]}  # base is unchanged
assert result == {"count": 5, "items": [1, 2, 3, 4]}

The recorded patches are exactly what diff would have produced: the same operation dicts, which can be applied with apply/iapply and serialized with to_json:

from patchdiff import apply, produce

base = {"count": 0}
result, patches, reverse_patches = produce(base, lambda d: d.update(count=5))

assert apply(base, patches) == result
assert apply(result, reverse_patches) == base

For small mutations to large states this is much faster than diffing, because the cost scales with the number of mutations instead of the size of the state. The produce-vs-diff groups in the benchmark suite quantify this.

Immutable by default

By default the recipe operates on a copy: base stays untouched and result is a new object. The draft is only wrapped in proxies while the recipe runs; the returned result is plain data.

In-place mutation

With in_place=True the draft is the base object. No copy is made, and mutations go straight through the proxy into it. Use this when the object's identity matters, most notably for observ reactive state, where the writes must land on the reactive proxy so watchers fire:

from patchdiff import produce

state = {"count": 0}

result, patches, reverse_patches = produce(
    state,
    lambda draft: draft.update(count=5),
    in_place=True,
)

assert result is state  # same object
assert state == {"count": 5}

You still get both patch directions, so in-place mutation with undo/redo doesn't cost a deep copy at all.

What the draft supports

The draft mirrors the wrapped container type. Dicts, lists and sets each get a dedicated proxy with the full mutating and reading API, including operators:

  • dicts: item access/assignment/deletion, get, pop, setdefault, update, clear, popitem, keys/values/items, |=, iteration, ...
  • lists: indexing and slicing (read and write), append, insert, extend, pop, remove, clear, reverse, sort, +=, *=, iteration, ...
  • sets: add, remove, discard, pop, clear, update, the in-place operators (|=, &=, -=, ^=) and their method forms, ...

Values you read from the draft are themselves wrapped, so nested mutations are tracked too, with correct deep paths, even when list indices shift under them:

from patchdiff import produce, to_json

base = {"todos": [{"done": False}, {"done": False}]}

def recipe(draft):
    first = draft["todos"][0]
    draft["todos"].insert(0, {"done": True})  # shifts the original items
    first["done"] = True                       # still records the right path

result, patches, reverse_patches = produce(base, recipe)

assert result["todos"][1]["done"] is True
assert '"path": "/todos/1/done"' in to_json(patches)

Snapshotting

Values recorded into patches are snapshotted (deep-copied, with proxies unwrapped) at the moment the mutation happens. Mutating an object after assigning it into the draft won't retroactively change earlier patches, and patches never share mutable state with the draft or the result.

The draft is only valid inside the recipe

When produce returns, all proxies are released. Don't hold on to the draft (or values read from it) for use outside the recipe; take what you need from result instead.