|
| 1 | +# purepatch |
| 2 | + |
| 3 | +[](https://github.com/adam2go/purepatch/actions/workflows/ci.yml) |
| 4 | +[](https://pypi.org/project/purepatch/) |
| 5 | +[](.github/workflows/ci.yml) |
| 6 | +[](LICENSE) |
| 7 | + |
| 8 | +**The patch engine for code agents, in pure Python.** Apply unified diffs |
| 9 | +and fuzzy search/replace edits with no git, no `patch` binary, no C |
| 10 | +extension — in sandboxes, Pyodide/WASM, Lambda, anywhere `pip install` |
| 11 | +works. And because it runs in-process, it applies a patch in ~25 µs where |
| 12 | +spawning a binary costs milliseconds. |
| 13 | + |
| 14 | +```sh |
| 15 | +pip install purepatch |
| 16 | +``` |
| 17 | + |
| 18 | +```python |
| 19 | +import purepatch |
| 20 | + |
| 21 | +new_text = purepatch.apply(diff_text, old_text) # unified diff -> text |
| 22 | +report = purepatch.apply_files(diff_text, root=".") # multi-file patch |
| 23 | +new_text = purepatch.apply_edit(text, search, replace) # fuzzy block edit |
| 24 | +``` |
| 25 | + |
| 26 | +```sh |
| 27 | +purepatch --dry-run < change.patch # the familiar CLI, agent-friendly |
| 28 | +purepatch -R < change.patch # un-apply |
| 29 | +``` |
| 30 | + |
| 31 | +## Why |
| 32 | + |
| 33 | +LLMs edit code by emitting **unified diffs** and **SEARCH/REPLACE blocks** |
| 34 | +— and both arrive slightly wrong: line numbers drifted, context rotted, |
| 35 | +indentation moved, trailing whitespace differs. The existing Python |
| 36 | +options either only *parse* diffs (unidiff) or are long abandoned |
| 37 | +(python-patch, last release 2019). So every agent framework re-implements |
| 38 | +patching, badly, or shells out to git. |
| 39 | + |
| 40 | +purepatch is that missing engine: |
| 41 | + |
| 42 | +- **GNU patch semantics for unified diffs**: cumulative offset tracking, |
| 43 | + bidirectional position search, fuzz degradation — verified against the |
| 44 | + real thing (below). |
| 45 | +- **A fuzzy edit ladder for LLM edit blocks**: exact match → trailing |
| 46 | + whitespace tolerance → indentation transplant (the block the model wrote |
| 47 | + at top level gets re-indented to where it actually lives). Refuses to |
| 48 | + guess on ambiguity. |
| 49 | +- **Errors an agent can act on**: failed matches report the closest |
| 50 | + near-miss (`closest match: line 41, 87% similar`) so the model can |
| 51 | + correct its edit instead of retrying blind. |
| 52 | +- Git extended headers understood: new/deleted files, renames, quoted |
| 53 | + paths, `\ No newline at end of file`, CRLF content. |
| 54 | + |
| 55 | +## Verified against GNU patch and git apply |
| 56 | + |
| 57 | +Following the [pure* series methodology](https://github.com/adam2go/purejq): |
| 58 | +behavior is checked by **differential testing against the reference |
| 59 | +implementations**, run in CI on every commit — |
| 60 | + |
| 61 | +- **500 random clean patches**: `purepatch ≡ GNU patch ≡ git apply ≡ |
| 62 | + expected output`, byte for byte; |
| 63 | +- **200 drift scenarios** (the file gained unrelated lines): offset |
| 64 | + behavior matches GNU patch exactly; |
| 65 | +- **200 rotted-context scenarios**: fuzz behavior matches GNU patch's |
| 66 | + output wherever GNU patch succeeds; |
| 67 | +- **300 property cases**: `apply(diff(a,b), a) == b` and |
| 68 | + `apply(diff(a,b), b, reverse=True) == a`. |
| 69 | + |
| 70 | +## Performance |
| 71 | + |
| 72 | +Per-application latency — how a code agent actually uses a patcher: one |
| 73 | +patch at a time. Spawn cost is the binaries' real cost; in-process is |
| 74 | +purepatch's real cost. Median of 7, three independent rounds (spread |
| 75 | +<10%), outputs verified equal before timing. Reproduce: |
| 76 | +`python tools/bench.py --verify`. |
| 77 | + |
| 78 | +| workload | purepatch (in-process) | GNU patch (spawn) | git apply (spawn) | |
| 79 | +|---|---:|---:|---:| |
| 80 | +| 200-line file, 5 edits | 0.025 ms | 2.6 ms (**~100×**) | 7.3 ms (**~290×**) | |
| 81 | +| 2k-line file, 30 edits | 0.17 ms | 2.8 ms (16×) | 7.9 ms (46×) | |
| 82 | +| 20k-line file, 200 edits | 1.6 ms | 5.3 ms (3.4×) | 15.5 ms (10×) | |
| 83 | + |
| 84 | +Fuzzy `apply_edit` on a 400-line file: ~0.01 ms per call. |
| 85 | + |
| 86 | +An agent loop applying hundreds of edits per session pays milliseconds |
| 87 | +total, not seconds — and needs no git in its sandbox. |
| 88 | + |
| 89 | +## API sketch |
| 90 | + |
| 91 | +```python |
| 92 | +purepatch.parse(text) -> PatchSet # inspect hunks/files |
| 93 | +purepatch.apply(patch, source, reverse=False, max_fuzz=2) -> str |
| 94 | +purepatch.apply_files(patch, root=".", strip=None, # strip auto-detected |
| 95 | + reverse=False, dry_run=False) -> ApplyReport |
| 96 | +purepatch.apply_edit(content, search, replace) -> str |
| 97 | +purepatch.find_block(content, search) -> (start, end, strategy) |
| 98 | +``` |
| 99 | + |
| 100 | +`ApplyReport.ok`, per-file actions (`patched/created/deleted/renamed/ |
| 101 | +failed`), and per-hunk offset/fuzz are all inspectable — log them and an |
| 102 | +agent can explain exactly what happened. |
| 103 | + |
| 104 | +Exceptions: `ParseError`, `HunkApplyError`, `NoMatchError` (with |
| 105 | +`closest_line` / `closest_similarity`), `AmbiguousMatchError` (with all |
| 106 | +locations). |
| 107 | + |
| 108 | +## Limitations (honest ones) |
| 109 | + |
| 110 | +- **Binary patches are rejected**, not applied. |
| 111 | +- File modes are parsed from git headers but not applied to the |
| 112 | + filesystem (chmod is on the roadmap). |
| 113 | +- `purepatch` the CLI covers the agent subset (`-p -d -R --fuzz |
| 114 | + --dry-run`), not every GNU patch flag. |
| 115 | +- Like GNU patch, fuzzy hunk placement can in principle pick a wrong spot |
| 116 | + in pathological inputs; `--fuzz 0` disables tolerance entirely. |
| 117 | + |
| 118 | +## License |
| 119 | + |
| 120 | +[MIT](LICENSE) |
0 commit comments