diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..4853d3e --- /dev/null +++ b/.github/workflows/docs.yml @@ -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 diff --git a/.gitignore b/.gitignore index 3025943..a2e67f6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist uv.lock .benchmarks/ .venv +site/ diff --git a/README.md b/README.md index 5ae9b84..b0c1be0 100644 --- a/README.md +++ b/README.md @@ -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 @@ -19,34 +17,18 @@ 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 @@ -54,43 +36,25 @@ 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. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..ec1b3ab --- /dev/null +++ b/docs/getting-started/installation.md @@ -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). diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md new file mode 100644 index 0000000..3ef153a --- /dev/null +++ b/docs/getting-started/quick-start.md @@ -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). diff --git a/docs/guide/applying.md b/docs/guide/applying.md new file mode 100644 index 0000000..c0dc18d --- /dev/null +++ b/docs/guide/applying.md @@ -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 +``` diff --git a/docs/guide/diffing.md b/docs/guide/diffing.md new file mode 100644 index 0000000..d6ea73f --- /dev/null +++ b/docs/guide/diffing.md @@ -0,0 +1,109 @@ +# Diffing + +[`diff`][patchdiff.diff.diff] recursively compares two objects and emits JSON-patch-style operations in **both directions**: + +```python +from patchdiff import apply, diff + +ops, reverse_ops = diff({"a": 1}, {"a": 2, "b": 3}) + +assert apply({"a": 1}, ops) == {"a": 2, "b": 3} +assert apply({"a": 2, "b": 3}, reverse_ops) == {"a": 1} +``` + +The comparison starts with a plain equality check โ€” if `input == output`, both lists are empty. Otherwise the strategy depends on the types involved. + +## What gets compared structurally + +Containers are compared recursively when **both sides** are container-like (duck-typed, so third-party proxies such as observ's reactive objects work too): + +| both sides haveโ€ฆ | treated as | operations emitted | +|---|---|---| +| `.append` | list | minimal edit script: adds, removes, replaces per index | +| `.keys` | dict | add/remove per key, recursion into common keys | +| `.add` | set | add/remove per element | + +Anything else โ€” scalars, but also **tuples and frozensets**, or two containers of different kinds โ€” is treated as an atomic value and replaced wholesale: + +```python +from patchdiff import diff +from patchdiff.pointer import Pointer + +ops, _ = diff({"t": (1, 2)}, {"t": (1, 3)}) + +assert ops == [{"op": "replace", "path": Pointer(["t"]), "value": (1, 3)}] +``` + +## Lists + +List diffing computes a **minimal edit script** (fewest adds/removes/replaces) between the two lists. Common prefixes and suffixes are stripped first, so localized changes in large lists stay cheap. When a replace pairs up two containers, patchdiff recurses into them instead of replacing the whole element: + +```python +from patchdiff import diff, to_json + +ops, _ = diff( + [1, {"name": "a"}, 3], + [1, {"name": "b"}, 3], +) + +assert to_json(ops) == '[{"op": "replace", "path": "/1/name", "value": "b"}]' +``` + +Insertions at the end use the JSON pointer `-` token (RFC 6901 for "append"): + +```python +from patchdiff import diff, to_json + +ops, _ = diff([1, 2], [1, 2, 3]) + +assert to_json(ops) == '[{"op": "add", "path": "/-", "value": 3}]' +``` + +## Dicts + +Keys only in the input become removes, keys only in the output become adds, and common keys are diffed recursively โ€” so nested changes produce deep paths rather than replacing whole subtrees: + +```python +from patchdiff import diff, to_json + +ops, _ = diff( + {"user": {"name": "kim", "age": 40}}, + {"user": {"name": "kim", "age": 41}}, +) + +assert to_json(ops) == '[{"op": "replace", "path": "/user/age", "value": 41}]' +``` + +## Sets + +Sets have no indices, so patchdiff extends JSON patch slightly: an element is **added** with the `-` token (like a list append) and **removed** by addressing the element itself as the final path token: + +```python +from patchdiff import diff +from patchdiff.pointer import Pointer + +ops, _ = diff({1, 2}, {2, 3}) + +assert ops == [ + {"op": "remove", "path": Pointer([1])}, + {"op": "add", "path": Pointer(["-"]), "value": 3}, +] +``` + +See [Gotchas](gotchas.md) for the places where this deliberately diverges from strict RFC 6902. + +## Reverse operations + +The second list `diff` returns is not just the first with `add`/`remove` swapped โ€” the operations are also **ordered for reverse application**, so indices resolve correctly as each patch is applied. Always apply `reverse_ops` as-is, in order: + +```python +from patchdiff import apply, diff + +input = [1, 2, 3, 4, 5] +output = [1, 4, 5, 6] + +ops, reverse_ops = diff(input, output) + +assert apply(input, ops) == output +assert apply(output, reverse_ops) == input +``` diff --git a/docs/guide/gotchas.md b/docs/guide/gotchas.md new file mode 100644 index 0000000..51766c8 --- /dev/null +++ b/docs/guide/gotchas.md @@ -0,0 +1,41 @@ +# Gotchas and Best Practices + +## Where patchdiff diverges from strict RFC 6902 + +Patchdiff's patches are JSON-patch *compliant* for JSON-shaped data (dicts with string keys, lists, scalars), but the library deliberately supports more of Python than JSON has: + +* **Sets** are diffed and patched natively: elements are added with the `-` token and removed by addressing the element value itself as the final path token. Strict RFC 6902 has no set concept at all. +* **Tuples and frozensets** are treated as atomic values โ€” they are never diffed into, only replaced wholesale. +* **Pointer tokens can be non-strings** (integer list indices, set members). They stringify losslessly for lists, but set-member tokens can't be parsed back from a string โ€” see [Serialization](serialization.md#non-json-values). +* **Only `add`, `remove` and `replace` are emitted.** `move`, `copy` and `test` from RFC 6902 are neither generated nor understood by [`apply`][patchdiff.apply.apply]/[`iapply`][patchdiff.apply.iapply]. + +If you feed patches to a strict third-party JSON patch implementation, stick to JSON-shaped data and everything lines up. + +## Apply patches in order, as a unit + +Paths refer to the state of the object *at that point in the patch sequence* โ€” list indices shift as adds and removes apply. Both lists returned by [`diff`][patchdiff.diff.diff] and [`produce`][patchdiff.produce.produce] are ordered for exactly this; reordering or cherry-picking operations from the middle of a list will corrupt paths. + +Also, reverse operations undo the *whole* forward list, not individual forward ops one-for-one. + +## Diffing compares by equality + +`diff` starts with `input == output`. Anything Python considers equal produces no patch โ€” including e.g. `1 == True` and `0.0 == 0`. If you need to normalize such values, do it before diffing. + +## Patches never share state with your objects + +Patch values are snapshotted when recorded (by `produce`) and deep-copied when applied (by `apply`/`iapply`). You can keep patch lists on an undo stack indefinitely and freely mutate your state โ€” they won't drift. The flip side: don't rely on object identity surviving a round-trip through patches; equality survives, identity doesn't. + +## `produce` drafts don't outlive the recipe + +The draft proxy (and everything you read from it) is only wired up while the recipe runs. When [`produce`][patchdiff.produce.produce] returns, proxies are released; use the returned `result` instead of stashing draft references. Values you *detach* from the draft during the recipe (e.g. `popped = draft["items"].pop()`) stop recording โ€” reinserting `popped` later records its plain data, which is usually what you want. + +## Choose `diff` or `produce` deliberately + +* Use **`diff`** when you receive two complete states (e.g. from a form, a file, an API response). Cost scales with the size of the structures. +* Use **`produce`** when your code performs the mutations. Cost scales with the number of mutations, which is usually far smaller than the state. + +The `produce-vs-diff` benchmark groups in the repository quantify the difference for typical shapes. + +## Use `in_place=True` for proxy-backed state + +For observ reactive objects (or anything where identity and write-through behavior matter), pass `in_place=True` to `produce` and use `iapply` rather than `apply` โ€” both write through the original object instead of replacing it. See [Observ Integration](observ.md). diff --git a/docs/guide/observ.md b/docs/guide/observ.md new file mode 100644 index 0000000..e69a616 --- /dev/null +++ b/docs/guide/observ.md @@ -0,0 +1,89 @@ +# Observ Integration + +[observ](https://github.com/fork-tongue/observ) provides reactive state for Python: mutate a `reactive` proxy and watchers and computed values update automatically. Patchdiff is observ's natural companion โ€” it turns those same mutations into patches, which is how you add **undo/redo or change synchronization on top of reactive state**. + +Patchdiff has no hard dependency on observ; everything on this page also degrades gracefully to plain dicts and lists. + +## Recording patches on reactive state + +Use [`produce`][patchdiff.produce.produce] with `in_place=True`. The mutations are written *through* observ's reactive proxy, so watchers fire exactly as if you had mutated the state directly โ€” and you get both patch directions for free: + +```python +from observ import reactive, watch + +from patchdiff import produce + +state = reactive({"count": 0}) + +observed = [] +watcher = watch( + lambda: state["count"], + lambda new: observed.append(new), + sync=True, +) + +result, patches, reverse_patches = produce( + state, + lambda draft: draft.update(count=5), + in_place=True, +) + +assert result is state # same reactive object +assert state["count"] == 5 # state was mutated... +assert observed == [5] # ...and the watcher fired +``` + +!!! note "Why `in_place=True`?" + Without it, `produce` copies the state and mutates the copy โ€” the reactive object stays untouched and no watcher fires. In-place mode is also faster, since it skips the deep copy. + +## Undo/redo for reactive state + +Applying patches with [`iapply`][patchdiff.apply.iapply] writes through the reactive proxy as well, so undo and redo trigger watchers like any other mutation: + +```python +from observ import reactive + +from patchdiff import iapply, produce + +state = reactive({"todos": ["write docs"]}) + +_, patches, reverse_patches = produce( + state, + lambda draft: draft["todos"].append("release 1.0"), + in_place=True, +) + +assert state["todos"] == ["write docs", "release 1.0"] + +iapply(state, reverse_patches) # undo +assert state["todos"] == ["write docs"] + +iapply(state, patches) # redo +assert state["todos"] == ["write docs", "release 1.0"] +``` + +## Diffing reactive state + +[`diff`][patchdiff.diff.diff] duck-types its inputs, so observ proxies can be diffed directly โ€” against plain data or other proxies: + +```python +from observ import reactive + +from patchdiff import diff, to_json + +state = reactive({"count": 0}) + +ops, _ = diff(state, {"count": 1}) + +assert to_json(ops) == '[{"op": "replace", "path": "/count", "value": 1}]' +``` + +Patch values recorded from reactive state are snapshotted to **plain data** (observ proxies are unwrapped), so patches stay serializable and never keep reactive objects alive. + +## Installing both + +The observ integration is exercised in patchdiff's own test suite. To develop against both: + +```sh +uv add patchdiff observ +``` diff --git a/docs/guide/pointers.md b/docs/guide/pointers.md new file mode 100644 index 0000000..93acb98 --- /dev/null +++ b/docs/guide/pointers.md @@ -0,0 +1,63 @@ +# JSON Pointers + +Every operation's `"path"` is a [`Pointer`][patchdiff.pointer.Pointer] โ€” patchdiff's implementation of a JSON pointer ([RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901)): a sequence of *reference tokens* that address a location inside a nested structure. + +```python +from patchdiff import diff + +ops, _ = diff({"a": {"b": [1]}}, {"a": {"b": [2]}}) + +ptr = ops[0]["path"] +assert ptr.tokens == ("a", "b", 0) +assert str(ptr) == "/a/b/0" +``` + +## Immutable, hashable, comparable + +Pointers are immutable โ€” [`append`][patchdiff.pointer.Pointer.append] returns a *new* pointer โ€” and they support equality and hashing, so they can be dict keys or set members: + +```python +from patchdiff.pointer import Pointer + +root = Pointer() +child = root.append("a").append(0) + +assert root.tokens == () +assert child == Pointer(["a", 0]) +assert str(child) == "/a/0" +``` + +## String form and escaping + +`str(pointer)` renders the RFC 6901 string form, escaping `~` as `~0` and `/` as `~1` inside tokens; [`Pointer.from_str`][patchdiff.pointer.Pointer.from_str] parses one back: + +```python +from patchdiff.pointer import Pointer + +ptr = Pointer(["a/b", "c~d"]) +assert str(ptr) == "/a~1b/c~0d" +assert Pointer.from_str("/a~1b/c~0d").tokens == ("a/b", "c~d") +``` + +Note that parsing is string-typed: `from_str` cannot know whether `"0"` was a list index or a dict key, so all parsed tokens are strings. That's fine for applying patches โ€” [`iapply`][patchdiff.apply.iapply] converts numeric-looking keys back to list indices as needed. + +## Resolving a pointer + +[`evaluate`][patchdiff.pointer.Pointer.evaluate] resolves a pointer against an object and returns `(parent, key, value)` โ€” the container holding the addressed leaf, the leaf's key in it, and its current value: + +```python +from patchdiff.pointer import Pointer + +obj = {"a": {"b": [10, 20]}} +parent, key, value = Pointer(["a", "b", 1]).evaluate(obj) + +assert parent is obj["a"]["b"] +assert key == 1 +assert value == 20 +``` + +The walk to the parent is strict โ€” a missing intermediate raises. Only the *leaf* may be missing (with a container parent), because that's a legitimate target for an `"add"`; its value then resolves to `None`. + +## Divergence from RFC 6901 + +Strict JSON pointers only contain string tokens. Patchdiff pointers can hold **arbitrary hashable values**: integer list indices stay integers, and set members are addressed by the member value itself (see [Diffing sets](diffing.md#sets)). Rendering to a string (or [`to_json`][patchdiff.serialize.to_json]) stringifies each token, which is lossy for non-string tokens โ€” see [Gotchas](gotchas.md). diff --git a/docs/guide/produce.md b/docs/guide/produce.md new file mode 100644 index 0000000..292e7c3 --- /dev/null +++ b/docs/guide/produce.md @@ -0,0 +1,90 @@ +# Proxy-Based Patch Generation + +Diffing compares two complete states after the fact. When *your code* is the thing making the changes, [`produce`][patchdiff.produce.produce] skips 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](https://immerjs.github.io/immer/produce). + +```python +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`][patchdiff.diff.diff] would have produced โ€” the same operation dicts, appliable with [`apply`][patchdiff.apply.apply]/[`iapply`][patchdiff.apply.iapply] and serializable with [`to_json`][patchdiff.serialize.to_json]: + +```python +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 โ€” see the `produce-vs-diff` groups in the benchmark suite. + +## 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. That's the mode to use when the object's identity matters, most notably for [observ](observ.md) reactive state, where the writes must land on the reactive proxy so watchers fire: + +```python +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 costs no 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: + +```python +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. + +!!! note "The draft is only valid inside the recipe" + When `produce` returns, all proxies are released. Don't stash the draft (or values read from it) for use outside the recipe โ€” take what you need from `result` instead. diff --git a/docs/guide/serialization.md b/docs/guide/serialization.md new file mode 100644 index 0000000..b716904 --- /dev/null +++ b/docs/guide/serialization.md @@ -0,0 +1,63 @@ +# Serialization + +Operation lists are plain data โ€” dicts with `"op"`, `"path"` and `"value"` keys โ€” except for the paths, which are [`Pointer`][patchdiff.pointer.Pointer] objects. [`to_json`][patchdiff.serialize.to_json] renders the paths to their JSON pointer string form and serializes the whole list to an RFC 6902 JSON patch document: + +```python +from patchdiff import diff, to_json + +ops, _ = diff({"a": [5, 7]}, {"a": [5, 2], "c": 7}) + +assert to_json(ops) == ( + '[{"op": "add", "path": "/c", "value": 7},' + ' {"op": "replace", "path": "/a/1", "value": 2}]' +) +``` + +Keyword arguments are forwarded to `json.dumps`, so `indent`, `sort_keys`, `default`, etc. all work: + +```python +from patchdiff import diff, to_json + +ops, _ = diff({"a": 1}, {"a": 2}) + +print(to_json(ops, indent=4)) +``` + +```json +[ + { + "op": "replace", + "path": "/a", + "value": 2 + } +] +``` + +If you only want the paths stringified but not the JSON encoding โ€” for example to hand patches to another JSON patch library, or a different serializer โ€” use [`to_str_paths`][patchdiff.serialize.to_str_paths]: + +```python +from patchdiff import diff +from patchdiff.serialize import to_str_paths + +ops, _ = diff({"a": 1}, {"a": 2}) + +assert to_str_paths(ops) == [{"op": "replace", "path": "/a", "value": 2}] +assert ops[0]["path"] != "/a" # the original ops are not mutated +``` + +## Non-JSON values + +Serialization is only lossless for JSON-representable structures. Two things to watch for: + +* **Values**: patches on structures containing sets, frozensets or tuples carry those objects in their `"value"` fields, and `json.dumps` can't encode them. Pass a `default=` to convert them (accepting that the type is lost), or keep such patches in memory instead. +* **Paths**: non-string pointer tokens (integer list indices, set members) are stringified. For lists that's exactly RFC 6901; for sets it means the *member itself* becomes a string token, which cannot be parsed back into the original value. + +```python +from patchdiff import diff, to_json + +ops, _ = diff({"tags": {"a"}}, {"tags": {"a", "b"}}) + +assert to_json(ops) == '[{"op": "add", "path": "/tags/-", "value": "b"}]' +``` + +See [Gotchas](gotchas.md) for the full list of RFC 6902 divergences. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..ec4238a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,52 @@ +# Patchdiff ๐Ÿ” + +Patchdiff computes **bidirectional, JSON-patch-compliant diffs** between composite Python data structures built out of dicts, lists, sets and tuples. Inspired by [rfc6902](https://github.com/chbrown/rfc6902), it has no dependencies and works on any Python >= 3.9. + +One call gives you both directions: + +```python +from patchdiff import apply, 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) + +assert apply(input, ops) == output +assert apply(output, reverse_ops) == input +``` + +That makes patchdiff a natural fit for: + +* **Undo/redo** โ€” apply `reverse_ops` to go back, `ops` to go forward again. +* **Synchronization** โ€” serialize patches with [`to_json`][patchdiff.serialize.to_json] and ship them over the wire instead of whole documents. +* **Change tracking** โ€” record exactly what a piece of code did to your state. + +Besides after-the-fact diffing, patchdiff can also record patches **while mutations happen**, through [`produce`][patchdiff.produce.produce] โ€” a proxy-based recorder inspired by [Immer](https://immerjs.github.io/immer/produce): + +```python +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 untouched +assert result == {"count": 5, "items": [1, 2, 3, 4]} +``` + +## Where to go next + +* Follow the [Quick Start](getting-started/quick-start.md) to learn the core API in a few minutes. +* Read the [Guide](guide/diffing.md) for an in-depth look at [diffing](guide/diffing.md), [applying patches](guide/applying.md), [pointers](guide/pointers.md), [proxy-based patch generation](guide/produce.md) and [serialization](guide/serialization.md). +* Using [observ](https://github.com/fork-tongue/observ)? See the [Observ Integration](guide/observ.md) page for reactive state with undo/redo. +* Browse the [API Reference](reference/api.md) for the complete public API. + +## Related projects + +* [observ](https://github.com/fork-tongue/observ): reactive state management for Python; patchdiff's `produce(..., in_place=True)` is built to work with its reactive proxies. +* [rfc6902](https://github.com/chbrown/rfc6902): the TypeScript library that inspired patchdiff's diffing approach. diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md new file mode 100644 index 0000000..5a2d726 --- /dev/null +++ b/docs/internals/architecture.md @@ -0,0 +1,58 @@ +# Architecture + +This page documents how patchdiff works under the hood. Nothing here is part of the public API โ€” it exists so that contributors (and the curious) can find their way around the four core modules: `pointer.py`, `diff.py`, `apply.py` and `produce.py`. + +## Pointers + +A [`Pointer`][patchdiff.pointer.Pointer] is a tuple of reference tokens behind `__slots__`. Immutability is what makes sharing safe: `append` builds a *new* pointer from `(*tokens, token)`, so the diff recursion can hand the same prefix pointer to many child operations without copies or aliasing bugs. Tokens are kept in their native Python types โ€” integers for list indices, arbitrary hashable values for set members โ€” and only stringified (with RFC 6901 `~0`/`~1` escaping) when a pointer is rendered. + +`Pointer.evaluate` resolves a path in two phases with deliberately different strictness: + +* the walk **to the parent** is strict โ€” a missing intermediate container raises, because silently landing on a partial parent would let `iapply` write to the wrong place; +* the **leaf** lookup tolerates `KeyError`/`IndexError`/`TypeError` and resolves to `None`, but only when the parent is a container that can be written into โ€” a missing leaf is a legitimate target for an `"add"` (dict inserts, the list `-` append token). + +## Diffing + +`diff()` dispatches on duck type: both sides having `.append` means list, `.keys` means dict, `.add` means set โ€” which is what lets observ proxies and other container look-alikes flow through unchanged. Everything else (scalars, tuples, frozensets, mismatched container kinds) is one `replace` op. The first check is always `input == output`; equal inputs short-circuit to empty patch lists. + +### Lists: DP edit script with prefix/suffix trimming + +`diff_lists` computes a minimal edit script in three steps: + +1. **Trim.** The common prefix and suffix are stripped first, so the expensive part only covers the changed region โ€” for the common case of a localized edit in a large list, this collapses the problem to a few elements. +2. **DP table.** A classic O(mยทn) Levenshtein-style table is built over the trimmed region, where `dp[i][j]` is the cost of transforming the first `i` input elements into the first `j` output elements (add, remove and replace all cost 1). +3. **Traceback and padding.** Walking the table backwards yields the operations in reverse order, indexed in trimmed-sublist coordinates. A second pass re-emits them in application order while tracking a running `padding` offset: every applied `add` shifts subsequent indices up by one, every `remove` shifts them down. Adds that land past the end of the list become the `-` (append) token. When a `replace` pairs two containers, `diff` recurses into them with the element's pointer as the new prefix, so nested changes become deep paths instead of wholesale element replacement. + +The reverse operations are built in the same traceback with input/output swapped, then ordered for reverse application. + +### Dicts and sets + +`diff_dicts` splits keys into three groups โ€” input-only (`remove`), output-only (`add`), and common (recurse). `diff_sets` is the same with elements instead of keys: removals address the element value itself as the final token; additions use the `-` token. In both cases the reverse lists are assembled so that applying them in order undoes the forward list applied in order. + +## Applying + +`iapply` is a straight interpreter: for each patch it resolves `path.evaluate(obj)` to `(parent, key, _)`, then dispatches on the parent's duck type. Dict writes are direct; list writes convert numeric string keys (from parsed pointers) back to `int` and translate `add` at `-` into `append`; set `add` inserts the value, set `remove` discards the *key* (the addressed element). Patch values are `deepcopy`'d before writing so the patch list and the patched object never share mutable state. `apply` is literally `iapply(deepcopy(obj), patches)`. + +## produce(): proxy-based recording + +`produce()` wraps the draft in a proxy tree (`DictProxy` / `ListProxy` / `SetProxy`, dispatched by duck type) and lets the recipe mutate it. Three design decisions shape the implementation: + +### Paths come from parent links, not stored strings + +A proxy stores only its parent proxy and its key within that parent โ€” never an absolute path. Its location is computed on demand by `_location()`, walking up to the root and collecting keys. This is what keeps recorded paths correct when the tree changes under a handed-out reference: when `insert(0, โ€ฆ)` shifts list elements, the list proxy renumbers the keys of its children (`_shift_cache`), and a child that was at index 0 now reports index 1 โ€” no stored path to invalidate. + +### Detachment stops recording + +Each proxy keeps a registry (`_proxies`) of the child proxies it has handed out. Removing or replacing an element marks the corresponding child proxy *detached*: it still mutates its underlying data (so user code holding it keeps working), but `_location()` returns `None` and nothing gets recorded โ€” its data will be captured by the snapshot of whatever write re-inserts it. Re-inserting a *detached* proxy directly is a move: `_adopt` re-attaches it at the new location, and later mutations through the held reference record at the new path. Adopting a still-attached proxy (or one wrapping duck-typed data) snapshots instead, since a value can only live in one place. + +Parent links are **weak references**, so the proxy tree contains no cycles and is reclaimed by plain reference counting the moment `produce` returns. `PatchRecorder.finalize` additionally detaches the root, which severs every remaining proxy's path to the root โ€” a proxy leaked out of the recipe can never append to the already-returned patch lists. + +### Values are snapshotted at record time + +Every non-scalar value that goes into a patch passes through `_snapshot`: a hand-rolled deep copy for the JSON-like types (dict, list, set, frozenset, tuple), which also replaces nested proxies with copies of their data; unknown types fall back to `copy.deepcopy`, which unwraps third-party proxies (like observ's) through their `__deepcopy__` hooks. Snapshotting at record time โ€” not at finalize โ€” is what makes patches immune to later mutations of the same object. + +Forward patches are appended in order; reverse patches are also appended (O(1)) and reversed once in `finalize`, since reverse application order is the mirror of forward order. `record_replace` compares old and new first and skips no-op writes entirely. + +### The proxy classes + +`DictProxy`, `ListProxy` and `SetProxy` implement the full mutating API of their container (including in-place operators like `|=`, `+=`, `^=`), each method recording the equivalent patch(es) before or after delegating to the underlying data. Read-only methods don't need per-method logic and are generated onto the classes by `_add_reader_methods` as plain pass-throughs. Reads that return containers wrap the result in a child proxy (cached in `_proxies`), which is how deep mutation tracking works without ever copying the draft eagerly. diff --git a/docs/reference/api.md b/docs/reference/api.md new file mode 100644 index 0000000..5ce937e --- /dev/null +++ b/docs/reference/api.md @@ -0,0 +1,31 @@ +# API Reference + +The public API is small: two ways to obtain patches ([`diff`][patchdiff.diff.diff] and [`produce`][patchdiff.produce.produce]), two ways to apply them ([`apply`][patchdiff.apply.apply] and [`iapply`][patchdiff.apply.iapply]), and serialization helpers. All of these are importable directly from `patchdiff`; the [`Pointer`][patchdiff.pointer.Pointer] class lives in `patchdiff.pointer`. + +## Diffing + +::: patchdiff.diff.diff + +## Applying patches + +::: patchdiff.apply.apply + +::: patchdiff.apply.iapply + +## Proxy-based patch generation + +::: patchdiff.produce.produce + +## Serialization + +::: patchdiff.serialize.to_json + +::: patchdiff.serialize.to_str_paths + +## Pointers + +::: patchdiff.pointer.Pointer + +::: patchdiff.pointer.escape + +::: patchdiff.pointer.unescape diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..938ee56 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,66 @@ +site_name: Patchdiff +site_description: Bidirectional, JSON-patch-compliant diffs between Python data structures +site_url: https://fork-tongue.github.io/patchdiff +repo_url: https://github.com/fork-tongue/patchdiff +repo_name: fork-tongue/patchdiff + +theme: + name: material + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: teal + accent: deep orange + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: teal + accent: deep orange + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.sections + - navigation.expand + - content.code.copy + +markdown_extensions: + - admonition + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - attr_list + +plugins: + - search + - mkdocstrings: + handlers: + python: + options: + show_source: false + show_root_heading: true + heading_level: 3 + members_order: source + separate_signature: true + +nav: + - Home: index.md + - Getting Started: + - Installation: getting-started/installation.md + - Quick Start: getting-started/quick-start.md + - Guide: + - Diffing: guide/diffing.md + - Applying Patches: guide/applying.md + - JSON Pointers: guide/pointers.md + - Proxy-Based Patch Generation: guide/produce.md + - Serialization: guide/serialization.md + - Observ Integration: guide/observ.md + - Gotchas and Best Practices: guide/gotchas.md + - Reference: + - API Reference: reference/api.md + - Internals: + - Architecture: internals/architecture.md diff --git a/patchdiff/apply.py b/patchdiff/apply.py index edc8b0a..f108788 100644 --- a/patchdiff/apply.py +++ b/patchdiff/apply.py @@ -5,7 +5,20 @@ def iapply(obj: Diffable, patches: List[Dict]) -> Diffable: - """Apply a set of patches to an object, in-place""" + """Apply a list of patches to an object, in place. + + Patch values are deep-copied as they are written, so mutating the + patched object afterwards never writes through into the patch list + (and vice versa). + + Args: + obj: The object to mutate. + patches: Operations as returned by [`diff`][patchdiff.diff.diff] + or [`produce`][patchdiff.produce.produce]. + + Returns: + The same object, mutated. + """ if not patches: return obj for patch in patches: @@ -44,5 +57,14 @@ def iapply(obj: Diffable, patches: List[Dict]) -> Diffable: def apply(obj: Diffable, patches: List[Dict]) -> Diffable: - """Apply a set of patches to a deep copy of an object""" + """Apply a list of patches to a deep copy of an object. + + Args: + obj: The object to copy and patch; it is left unchanged. + patches: Operations as returned by [`diff`][patchdiff.diff.diff] + or [`produce`][patchdiff.produce.produce]. + + Returns: + The patched copy. + """ return iapply(deepcopy(obj), patches) diff --git a/patchdiff/diff.py b/patchdiff/diff.py index 8683783..5a537a4 100644 --- a/patchdiff/diff.py +++ b/patchdiff/diff.py @@ -215,6 +215,27 @@ def diff_sets(input: Set, output: Set, ptr: Pointer) -> Tuple[List, List]: def diff( input: Diffable, output: Diffable, ptr: Pointer | None = None ) -> Tuple[List, List]: + """Compute the difference between two objects as JSON patch operations. + + Recursively compares `input` and `output` and returns operations in + both directions. Dicts, lists and sets are compared structurally; + any other value (scalars, but also tuples and frozensets) is treated + as atomic and replaced wholesale when it differs. + + Args: + input: The source object. + output: The target object. + ptr: Pointer prefix for the emitted operations; used internally + during recursion. Leave as `None` to diff from the root. + + Returns: + A tuple `(ops, reverse_ops)`: applying `ops` to `input` yields + `output`, and applying `reverse_ops` to `output` yields `input` + again. Each operation is a dict with an `"op"` key (`"add"`, + `"remove"` or `"replace"`), a `"path"` key holding a + [`Pointer`][patchdiff.pointer.Pointer], and a `"value"` key for + add/replace operations. + """ if input == output: return [], [] if ptr is None: diff --git a/patchdiff/pointer.py b/patchdiff/pointer.py index 60e07bc..3c7ab46 100644 --- a/patchdiff/pointer.py +++ b/patchdiff/pointer.py @@ -6,14 +6,27 @@ def unescape(token: str) -> str: + """Decode a JSON pointer reference token (`~1` โ†’ `/`, `~0` โ†’ `~`).""" return token.replace("~1", "/").replace("~0", "~") def escape(token: str) -> str: + """Encode a reference token for a JSON pointer string (`~` โ†’ `~0`, `/` โ†’ `~1`).""" return token.replace("~", "~0").replace("/", "~1") class Pointer: + """A JSON pointer (RFC 6901): a path into a nested structure. + + A pointer is an immutable sequence of reference tokens; + [`append`][patchdiff.pointer.Pointer.append] returns a new pointer + rather than mutating. `str()` renders the escaped JSON pointer + string form and [`from_str`][patchdiff.pointer.Pointer.from_str] + parses one back. Unlike strict RFC 6901, tokens can be arbitrary + hashable values (not just strings), so pointers can address set + members and integer list indices directly. + """ + __slots__ = ("tokens",) def __init__(self, tokens: Iterable[Hashable] | None = None) -> None: @@ -23,6 +36,12 @@ def __init__(self, tokens: Iterable[Hashable] | None = None) -> None: @staticmethod def from_str(path: str) -> "Pointer": + """Parse an escaped JSON pointer string (e.g. `"/a/0/b"`) into a Pointer. + + All tokens are parsed as strings; numeric list indices become + string tokens, which `evaluate` and `iapply` convert back as + needed. + """ tokens = [unescape(t) for t in path.split("/")[1:]] return Pointer(tokens) @@ -41,6 +60,15 @@ def __eq__(self, other: Any) -> bool: return self.tokens == other.tokens def evaluate(self, obj: Diffable) -> tuple[Diffable, Hashable, Any]: + """Resolve the pointer against an object. + + Returns: + A tuple `(parent, key, value)`: the container holding the + addressed leaf, the leaf's key within that container, and + the leaf's current value โ€” `None` when the leaf does not + exist yet (the target of an `"add"`, or a list append via + the `"-"` token). + """ key = "" parent = None cursor = obj @@ -69,5 +97,5 @@ def evaluate(self, obj: Diffable) -> tuple[Diffable, Hashable, Any]: return parent, key, cursor def append(self, token: Hashable) -> "Pointer": - """append, creating new Pointer""" + """Return a new Pointer with `token` appended; self is unchanged.""" return Pointer((*self.tokens, token)) diff --git a/patchdiff/serialize.py b/patchdiff/serialize.py index e2eab68..c6d5843 100644 --- a/patchdiff/serialize.py +++ b/patchdiff/serialize.py @@ -3,9 +3,20 @@ def to_str_paths(ops: List) -> List: + """Return a copy of the operations with each path rendered as a string. + + The [`Pointer`][patchdiff.pointer.Pointer] objects in the `"path"` + fields are replaced by their escaped JSON pointer (RFC 6901) string + form; the operations themselves are not mutated. + """ return [{**op, "path": str(op["path"])} for op in ops] def to_json(ops: List, **kwargs) -> str: + """Serialize a list of operations to a JSON patch (RFC 6902) string. + + Pointer paths are rendered as JSON pointer strings; any keyword + arguments (like `indent`) are forwarded to `json.dumps`. + """ str_ops = to_str_paths(ops) return json.dumps(str_ops, **kwargs) diff --git a/pyproject.toml b/pyproject.toml index 0eab9aa..ee97623 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,11 @@ ruff = ["ruff"] observ = [ "observ>=0.18.0", ] +docs = [ + "mkdocs>=1,<2", + "mkdocs-material", + "mkdocstrings[python]", +] [tool.ruff.lint] extend-select = [