Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
name: Docs

on:
push:
branches:
- master

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
cache-dependency-glob: "pyproject.toml"
- name: Install docs dependencies
run: uv sync --only-group docs
- name: Build docs
run: uv run --no-sync mkdocs build
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
path: site

deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ dist
uv.lock
.benchmarks/
.venv
site/
74 changes: 19 additions & 55 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@

# Patchdiff 🔍

Based on [rfc6902](https://github.com/chbrown/rfc6902) this library provides a simple API to generate bi-directional diffs between composite python datastructures composed out of lists, sets, tuples and dicts. The diffs are jsonpatch compliant, and can optionally be serialized to json format. Patchdiff can also be used to apply lists of patches to objects, both in-place or on a deepcopy of the input.
**Bidirectional, JSON-patch-compliant diffs between Python data structures.**

## Install

`pip install patchdiff`
📖 [Documentation](https://fork-tongue.github.io/patchdiff/) — [Quick Start](https://fork-tongue.github.io/patchdiff/getting-started/quick-start/) — [API Reference](https://fork-tongue.github.io/patchdiff/reference/api/)

## Quick-start
Patchdiff diffs composite structures of dicts, lists, sets and tuples, and gives you **both directions** in one call: the patches to get from `input` to `output`, and the patches to get back. Patches are [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) JSON-patch style, serializable to JSON, and can be applied in place or to a copy — which makes undo/redo, change synchronization and state auditing one-liners.

```python
from patchdiff import apply, diff, iapply, to_json
Expand All @@ -19,78 +17,44 @@ output = {"a": [5, 2, 9, {"b", "c"}], "b": 6, "c": 7}

ops, reverse_ops = diff(input, output)

assert apply(input, ops) == output
assert apply(output, reverse_ops) == input
assert apply(input, ops) == output # patch a copy...
assert apply(output, reverse_ops) == input # ...and it round-trips

iapply(input, ops) # apply in-place
iapply(input, ops) # or patch in place
assert input == output

print(to_json(ops, indent=4))
# [
# {
# "op": "add",
# "path": "/c",
# "value": 7
# },
# {
# "op": "replace",
# "path": "/a/1",
# "value": 2
# },
# {
# "op": "remove",
# "path": "/a/3/a"
# }
# ]
```

## Proxy-based patch generation
## Don't diff — record

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)):
When your own code makes the changes, `produce()` (inspired by [Immer](https://immerjs.github.io/immer/produce)) skips the comparison entirely: it hands your recipe a draft, records every mutation, and returns the result plus both patch directions. Cost scales with the number of mutations instead of the size of the state:

```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"}
# ]
assert base == {"count": 0, "items": [1, 2, 3]} # base is untouched
assert result == {"count": 5, "items": [1, 2, 3, 4]}
```

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.
With `in_place=True`, mutations (and patches applied with `iapply`) write straight through proxy-backed state — the natural companion to [observ](https://github.com/fork-tongue/observ) reactive objects, where mutating through the proxy is what triggers watchers. See [Observ Integration](https://fork-tongue.github.io/patchdiff/guide/observ/) for reactive state with undo/redo.

```python
from observ import reactive
from patchdiff import produce
## Install

state = reactive({"count": 0})
```sh
pip install patchdiff # or: uv add patchdiff
```

# Mutate in place and get patches for undo/redo
result, patches, reverse = produce(
state,
lambda draft: draft.update({"count": 5}),
in_place=True,
)
No dependencies, Python >= 3.9.

assert result is state # Same object
assert state["count"] == 5 # State was mutated, watchers triggered
```
## Learn more

The [documentation](https://fork-tongue.github.io/patchdiff/) covers [diffing semantics](https://fork-tongue.github.io/patchdiff/guide/diffing/), [applying patches](https://fork-tongue.github.io/patchdiff/guide/applying/), [JSON pointers](https://fork-tongue.github.io/patchdiff/guide/pointers/), [proxy-based patch generation](https://fork-tongue.github.io/patchdiff/guide/produce/), [serialization](https://fork-tongue.github.io/patchdiff/guide/serialization/) and the [gotchas](https://fork-tongue.github.io/patchdiff/guide/gotchas/), plus the [internals](https://fork-tongue.github.io/patchdiff/internals/architecture/) for the curious.
15 changes: 15 additions & 0 deletions docs/getting-started/installation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Installation

Patchdiff is published on [PyPI](https://pypi.org/project/patchdiff/) and has **no dependencies**. It requires Python 3.9 or newer.

```sh
pip install patchdiff
```

Or with [uv](https://docs.astral.sh/uv/):

```sh
uv add patchdiff
```

That's it — head over to the [Quick Start](quick-start.md).
98 changes: 98 additions & 0 deletions docs/getting-started/quick-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Quick Start

This page walks through the whole API in a few minutes: diff two structures, apply the patches, undo them, and serialize them to JSON.

## Diff two structures

[`diff`][patchdiff.diff.diff] compares two objects and returns two lists of operations: one that turns `input` into `output`, and one that turns `output` back into `input`.

```python
from patchdiff import diff

input = {"a": [5, 7, 9, {"a", "b", "c"}], "b": 6}
output = {"a": [5, 2, 9, {"b", "c"}], "b": 6, "c": 7}

ops, reverse_ops = diff(input, output)
```

Each operation is a plain dict in JSON patch style — an `"op"` (`"add"`, `"remove"` or `"replace"`), a `"path"` (a [`Pointer`][patchdiff.pointer.Pointer]), and a `"value"` for adds and replaces:

```python
from patchdiff import diff
from patchdiff.pointer import Pointer

ops, reverse_ops = diff({"count": 0}, {"count": 1})

assert ops == [{"op": "replace", "path": Pointer(["count"]), "value": 1}]
assert reverse_ops == [{"op": "replace", "path": Pointer(["count"]), "value": 0}]
```

## Apply patches

[`apply`][patchdiff.apply.apply] patches a **deep copy** and leaves the original untouched; [`iapply`][patchdiff.apply.iapply] patches the object **in place**:

```python
from patchdiff import apply, diff, iapply

input = {"a": [5, 7, 9], "b": 6}
output = {"a": [5, 2, 9], "b": 6, "c": 7}

ops, reverse_ops = diff(input, output)

assert apply(input, ops) == output # input is unchanged
assert apply(output, reverse_ops) == input # ...and it round-trips

iapply(input, ops) # in-place
assert input == output
```

Applying `reverse_ops` is your undo; re-applying `ops` is your redo.

## Serialize to JSON

Patches are JSON-patch compliant, so they can be serialized with [`to_json`][patchdiff.serialize.to_json] and shipped anywhere:

```python
from patchdiff import diff, to_json

ops, _ = diff({"a": [5, 7]}, {"a": [5, 2], "c": 7})

print(to_json(ops, indent=4))
```

```json
[
{
"op": "add",
"path": "/c",
"value": 7
},
{
"op": "replace",
"path": "/a/1",
"value": 2
}
]
```

## Record patches while mutating

When you're the one making the changes, you don't need to diff at all: [`produce`][patchdiff.produce.produce] hands you a draft, records every mutation you make to it, and gives you the result plus both patch directions — without a full comparison pass:

```python
from patchdiff import produce

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

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

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

assert base == {"count": 0, "items": [1, 2, 3]} # untouched
assert result == {"count": 5, "items": [1, 2, 3, 4], "new_field": "hello"}
```

Read on in the [Guide](../guide/diffing.md) for the details of each of these, or jump to the [API Reference](../reference/api.md).
77 changes: 77 additions & 0 deletions docs/guide/applying.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Applying Patches

Patchdiff can apply a list of operations to an object in two ways:

* [`apply`][patchdiff.apply.apply] first makes a **deep copy**, patches that, and returns it — the input is left untouched.
* [`iapply`][patchdiff.apply.iapply] patches the object **in place** and returns the same object. This is faster (no copy) and is what you want for objects that must keep their identity, such as observ reactive proxies.

```python
from patchdiff import apply, diff, iapply

input = {"a": [5, 7, 9], "b": 6}
output = {"a": [5, 2, 9], "b": 6, "c": 7}

ops, reverse_ops = diff(input, output)

patched = apply(input, ops)
assert patched == output
assert input == {"a": [5, 7, 9], "b": 6} # unchanged

result = iapply(input, ops)
assert result is input # same object, mutated
assert input == output
```

## Order matters

Operations are applied sequentially, and paths refer to the state of the object *at that point in the sequence* — exactly like RFC 6902. List indices in particular shift as adds and removes are applied, and both lists that [`diff`][patchdiff.diff.diff] returns are already ordered accordingly. Apply them as-is; don't reorder or cherry-pick individual operations.

## Undo and redo

Because every diff comes with its reverse, undo/redo is just a pair of stacks of patch lists:

```python
from patchdiff import diff, iapply

state = {"count": 0}
undo_stack = []
redo_stack = []

def update(new_state):
ops, reverse_ops = diff(state, new_state)
iapply(state, ops)
undo_stack.append((ops, reverse_ops))
redo_stack.clear()

def undo():
ops, reverse_ops = undo_stack.pop()
iapply(state, reverse_ops)
redo_stack.append((ops, reverse_ops))

def redo():
ops, reverse_ops = redo_stack.pop()
iapply(state, ops)
undo_stack.append((ops, reverse_ops))

update({"count": 1})
update({"count": 2})
undo()
assert state == {"count": 1}
redo()
assert state == {"count": 2}
```

## Patch values are copied on write

When a patch is applied, its `"value"` is **deep-copied** before being written into the target. The patched object and the patch list therefore never share mutable state — you can keep patches around (say, on an undo stack) and freely mutate the object afterwards:

```python
from patchdiff import diff, iapply

ops, _ = diff({}, {"items": [1, 2]})

state = iapply({}, ops)
state["items"].append(3)

assert ops[0]["value"] == [1, 2] # the patch is not affected
```
Loading