Skip to content

Commit fa1df95

Browse files
adam2goclaude
andcommitted
purepatch: apply unified diffs and fuzzy edits in pure Python
The patch engine for code agents - no git, no patch binary. GNU patch semantics (offset search, fuzz) verified by differential testing: 500 random patches three-way identical (purepatch / GNU patch / git apply), 200 drift and 200 fuzz scenarios byte-identical with GNU patch, 300 round-trip property cases. Fuzzy SEARCH/REPLACE ladder with indentation transplant and self-correction diagnostics for LLM edit blocks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0 parents  commit fa1df95

17 files changed

Lines changed: 1796 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
strategy:
12+
fail-fast: false
13+
matrix:
14+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "pypy3.11"]
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-python@v5
18+
with:
19+
python-version: ${{ matrix.python-version }}
20+
- run: pip install -e . pytest
21+
- run: pytest

.github/workflows/release.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: Release to PyPI
2+
3+
on:
4+
release:
5+
types: [published]
6+
7+
jobs:
8+
build:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- uses: actions/checkout@v4
12+
- uses: actions/setup-python@v5
13+
with:
14+
python-version: "3.12"
15+
- run: pip install build twine
16+
- run: python -m build
17+
- run: twine check dist/*
18+
- name: Test the built wheel
19+
run: pip install dist/*.whl pytest && pytest
20+
shell: bash
21+
- uses: actions/upload-artifact@v4
22+
with:
23+
name: dist
24+
path: dist/
25+
26+
publish:
27+
needs: build
28+
runs-on: ubuntu-latest
29+
environment: pypi
30+
permissions:
31+
id-token: write # trusted publishing (OIDC), no API token needed
32+
steps:
33+
- uses: actions/download-artifact@v4
34+
with:
35+
name: dist
36+
path: dist/
37+
- uses: pypa/gh-action-pypi-publish@release/v1

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
__pycache__/
2+
*.py[cod]
3+
*.egg-info/
4+
.venv/
5+
dist/
6+
build/
7+
.pytest_cache/
8+
.DS_Store
9+
HANDOVER.md

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 adam2go
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# purepatch
2+
3+
[![CI](https://github.com/adam2go/purepatch/actions/workflows/ci.yml/badge.svg)](https://github.com/adam2go/purepatch/actions/workflows/ci.yml)
4+
[![PyPI](https://img.shields.io/pypi/v/purepatch)](https://pypi.org/project/purepatch/)
5+
[![Python](https://img.shields.io/badge/python-3.9%E2%80%933.14%20%7C%20PyPy-blue)](.github/workflows/ci.yml)
6+
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](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)

pyproject.toml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
[build-system]
2+
requires = ["setuptools>=68"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "purepatch"
7+
version = "0.1.0"
8+
description = "Apply unified diffs and fuzzy search/replace edits in pure Python - the patch engine for code agents. No git, no patch binary."
9+
readme = "README.md"
10+
requires-python = ">=3.9"
11+
license = { text = "MIT" }
12+
authors = [{ name = "adam2go" }]
13+
keywords = ["patch", "diff", "unified-diff", "apply", "git", "llm", "agents", "pure-python"]
14+
classifiers = [
15+
"Development Status :: 3 - Alpha",
16+
"Intended Audience :: Developers",
17+
"License :: OSI Approved :: MIT License",
18+
"Programming Language :: Python :: 3",
19+
"Programming Language :: Python :: 3.9",
20+
"Programming Language :: Python :: 3.10",
21+
"Programming Language :: Python :: 3.11",
22+
"Programming Language :: Python :: 3.12",
23+
"Programming Language :: Python :: 3.13",
24+
"Programming Language :: Python :: 3.14",
25+
"Programming Language :: Python :: Implementation :: CPython",
26+
"Programming Language :: Python :: Implementation :: PyPy",
27+
"Topic :: Software Development :: Libraries",
28+
"Topic :: Software Development :: Version Control",
29+
]
30+
31+
[project.urls]
32+
Homepage = "https://github.com/adam2go/purepatch"
33+
34+
[project.scripts]
35+
purepatch = "purepatch.cli:main"
36+
37+
[tool.setuptools.packages.find]
38+
where = ["src"]
39+
40+
[tool.pytest.ini_options]
41+
testpaths = ["tests"]

src/purepatch/__init__.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""purepatch: apply unified diffs and fuzzy edits in pure Python.
2+
3+
The patch engine for code agents - no git, no patch binary.
4+
5+
import purepatch
6+
7+
new_text = purepatch.apply(diff_text, old_text) # unified diff
8+
report = purepatch.apply_files(diff_text, root=".") # whole tree
9+
new_text = purepatch.apply_edit(text, search, replace) # fuzzy block edit
10+
"""
11+
from __future__ import annotations
12+
13+
from .api import ApplyReport, FileReport, apply, apply_files # noqa: F401
14+
from .errors import (AmbiguousMatchError, HunkApplyError, # noqa: F401
15+
NoMatchError, ParseError, PatchError)
16+
from .fuzzy import apply_edit, find_block # noqa: F401
17+
from .parser import FilePatch, Hunk, PatchSet, parse # noqa: F401
18+
19+
__version__ = "0.1.0"
20+
__all__ = ["parse", "apply", "apply_files", "apply_edit", "find_block",
21+
"PatchSet", "FilePatch", "Hunk", "ApplyReport", "FileReport",
22+
"PatchError", "ParseError", "HunkApplyError", "NoMatchError",
23+
"AmbiguousMatchError"]

0 commit comments

Comments
 (0)