apply-patch-py 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. apply_patch_py-0.1.0/.github/workflows/lint.yml +29 -0
  2. apply_patch_py-0.1.0/.github/workflows/test.yml +29 -0
  3. apply_patch_py-0.1.0/.gitignore +12 -0
  4. apply_patch_py-0.1.0/.python-version +1 -0
  5. apply_patch_py-0.1.0/LICENSE +21 -0
  6. apply_patch_py-0.1.0/Makefile +48 -0
  7. apply_patch_py-0.1.0/PKG-INFO +7 -0
  8. apply_patch_py-0.1.0/README.md +0 -0
  9. apply_patch_py-0.1.0/main.py +0 -0
  10. apply_patch_py-0.1.0/pyproject.toml +46 -0
  11. apply_patch_py-0.1.0/src/apply_patch_py/__init__.py +12 -0
  12. apply_patch_py-0.1.0/src/apply_patch_py/applier.py +239 -0
  13. apply_patch_py-0.1.0/src/apply_patch_py/cli.py +50 -0
  14. apply_patch_py-0.1.0/src/apply_patch_py/constants.py +36 -0
  15. apply_patch_py-0.1.0/src/apply_patch_py/models.py +65 -0
  16. apply_patch_py-0.1.0/src/apply_patch_py/parser.py +249 -0
  17. apply_patch_py-0.1.0/src/apply_patch_py/search.py +94 -0
  18. apply_patch_py-0.1.0/src/apply_patch_py/utils.py +9 -0
  19. apply_patch_py-0.1.0/tests/conftest.py +13 -0
  20. apply_patch_py-0.1.0/tests/fixtures/scenarios/.gitattributes +1 -0
  21. apply_patch_py-0.1.0/tests/fixtures/scenarios/001_add_file/expected/bar.md +1 -0
  22. apply_patch_py-0.1.0/tests/fixtures/scenarios/001_add_file/patch.txt +4 -0
  23. apply_patch_py-0.1.0/tests/fixtures/scenarios/002_multiple_operations/expected/modify.txt +2 -0
  24. apply_patch_py-0.1.0/tests/fixtures/scenarios/002_multiple_operations/expected/nested/new.txt +1 -0
  25. apply_patch_py-0.1.0/tests/fixtures/scenarios/002_multiple_operations/input/delete.txt +1 -0
  26. apply_patch_py-0.1.0/tests/fixtures/scenarios/002_multiple_operations/input/modify.txt +2 -0
  27. apply_patch_py-0.1.0/tests/fixtures/scenarios/002_multiple_operations/patch.txt +9 -0
  28. apply_patch_py-0.1.0/tests/fixtures/scenarios/003_multiple_chunks/expected/multi.txt +4 -0
  29. apply_patch_py-0.1.0/tests/fixtures/scenarios/003_multiple_chunks/input/multi.txt +4 -0
  30. apply_patch_py-0.1.0/tests/fixtures/scenarios/003_multiple_chunks/patch.txt +9 -0
  31. apply_patch_py-0.1.0/tests/fixtures/scenarios/004_move_to_new_directory/expected/old/other.txt +1 -0
  32. apply_patch_py-0.1.0/tests/fixtures/scenarios/004_move_to_new_directory/expected/renamed/dir/name.txt +1 -0
  33. apply_patch_py-0.1.0/tests/fixtures/scenarios/004_move_to_new_directory/input/old/name.txt +1 -0
  34. apply_patch_py-0.1.0/tests/fixtures/scenarios/004_move_to_new_directory/input/old/other.txt +1 -0
  35. apply_patch_py-0.1.0/tests/fixtures/scenarios/004_move_to_new_directory/patch.txt +7 -0
  36. apply_patch_py-0.1.0/tests/fixtures/scenarios/005_rejects_empty_patch/expected/foo.txt +1 -0
  37. apply_patch_py-0.1.0/tests/fixtures/scenarios/005_rejects_empty_patch/input/foo.txt +1 -0
  38. apply_patch_py-0.1.0/tests/fixtures/scenarios/005_rejects_empty_patch/patch.txt +2 -0
  39. apply_patch_py-0.1.0/tests/fixtures/scenarios/006_rejects_missing_context/expected/modify.txt +2 -0
  40. apply_patch_py-0.1.0/tests/fixtures/scenarios/006_rejects_missing_context/input/modify.txt +2 -0
  41. apply_patch_py-0.1.0/tests/fixtures/scenarios/006_rejects_missing_context/patch.txt +6 -0
  42. apply_patch_py-0.1.0/tests/fixtures/scenarios/007_rejects_missing_file_delete/expected/foo.txt +1 -0
  43. apply_patch_py-0.1.0/tests/fixtures/scenarios/007_rejects_missing_file_delete/input/foo.txt +1 -0
  44. apply_patch_py-0.1.0/tests/fixtures/scenarios/007_rejects_missing_file_delete/patch.txt +3 -0
  45. apply_patch_py-0.1.0/tests/fixtures/scenarios/008_rejects_empty_update_hunk/expected/foo.txt +1 -0
  46. apply_patch_py-0.1.0/tests/fixtures/scenarios/008_rejects_empty_update_hunk/input/foo.txt +1 -0
  47. apply_patch_py-0.1.0/tests/fixtures/scenarios/008_rejects_empty_update_hunk/patch.txt +3 -0
  48. apply_patch_py-0.1.0/tests/fixtures/scenarios/009_requires_existing_file_for_update/expected/foo.txt +1 -0
  49. apply_patch_py-0.1.0/tests/fixtures/scenarios/009_requires_existing_file_for_update/input/foo.txt +1 -0
  50. apply_patch_py-0.1.0/tests/fixtures/scenarios/009_requires_existing_file_for_update/patch.txt +6 -0
  51. apply_patch_py-0.1.0/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/old/other.txt +1 -0
  52. apply_patch_py-0.1.0/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/renamed/dir/name.txt +1 -0
  53. apply_patch_py-0.1.0/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/old/name.txt +1 -0
  54. apply_patch_py-0.1.0/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/old/other.txt +1 -0
  55. apply_patch_py-0.1.0/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/renamed/dir/name.txt +1 -0
  56. apply_patch_py-0.1.0/tests/fixtures/scenarios/010_move_overwrites_existing_destination/patch.txt +7 -0
  57. apply_patch_py-0.1.0/tests/fixtures/scenarios/011_add_overwrites_existing_file/expected/duplicate.txt +1 -0
  58. apply_patch_py-0.1.0/tests/fixtures/scenarios/011_add_overwrites_existing_file/input/duplicate.txt +1 -0
  59. apply_patch_py-0.1.0/tests/fixtures/scenarios/011_add_overwrites_existing_file/patch.txt +4 -0
  60. apply_patch_py-0.1.0/tests/fixtures/scenarios/012_delete_directory_fails/expected/dir/foo.txt +1 -0
  61. apply_patch_py-0.1.0/tests/fixtures/scenarios/012_delete_directory_fails/input/dir/foo.txt +1 -0
  62. apply_patch_py-0.1.0/tests/fixtures/scenarios/012_delete_directory_fails/patch.txt +3 -0
  63. apply_patch_py-0.1.0/tests/fixtures/scenarios/013_rejects_invalid_hunk_header/expected/foo.txt +1 -0
  64. apply_patch_py-0.1.0/tests/fixtures/scenarios/013_rejects_invalid_hunk_header/input/foo.txt +1 -0
  65. apply_patch_py-0.1.0/tests/fixtures/scenarios/013_rejects_invalid_hunk_header/patch.txt +3 -0
  66. apply_patch_py-0.1.0/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/expected/no_newline.txt +2 -0
  67. apply_patch_py-0.1.0/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/input/no_newline.txt +1 -0
  68. apply_patch_py-0.1.0/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/patch.txt +7 -0
  69. apply_patch_py-0.1.0/tests/fixtures/scenarios/015_failure_after_partial_success_leaves_changes/expected/created.txt +1 -0
  70. apply_patch_py-0.1.0/tests/fixtures/scenarios/015_failure_after_partial_success_leaves_changes/patch.txt +8 -0
  71. apply_patch_py-0.1.0/tests/fixtures/scenarios/016_pure_addition_update_chunk/expected/input.txt +4 -0
  72. apply_patch_py-0.1.0/tests/fixtures/scenarios/016_pure_addition_update_chunk/input/input.txt +2 -0
  73. apply_patch_py-0.1.0/tests/fixtures/scenarios/016_pure_addition_update_chunk/patch.txt +6 -0
  74. apply_patch_py-0.1.0/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/expected/foo.txt +1 -0
  75. apply_patch_py-0.1.0/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/input/foo.txt +1 -0
  76. apply_patch_py-0.1.0/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/patch.txt +6 -0
  77. apply_patch_py-0.1.0/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/expected/file.txt +1 -0
  78. apply_patch_py-0.1.0/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/input/file.txt +1 -0
  79. apply_patch_py-0.1.0/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/patch.txt +6 -0
  80. apply_patch_py-0.1.0/tests/fixtures/scenarios/019_unicode_simple/expected/foo.txt +3 -0
  81. apply_patch_py-0.1.0/tests/fixtures/scenarios/019_unicode_simple/input/foo.txt +3 -0
  82. apply_patch_py-0.1.0/tests/fixtures/scenarios/019_unicode_simple/patch.txt +7 -0
  83. apply_patch_py-0.1.0/tests/fixtures/scenarios/020_delete_file_success/expected/keep.txt +1 -0
  84. apply_patch_py-0.1.0/tests/fixtures/scenarios/020_delete_file_success/input/keep.txt +1 -0
  85. apply_patch_py-0.1.0/tests/fixtures/scenarios/020_delete_file_success/input/obsolete.txt +1 -0
  86. apply_patch_py-0.1.0/tests/fixtures/scenarios/020_delete_file_success/patch.txt +3 -0
  87. apply_patch_py-0.1.0/tests/fixtures/scenarios/020_whitespace_padded_patch_marker_lines/expected/file.txt +1 -0
  88. apply_patch_py-0.1.0/tests/fixtures/scenarios/020_whitespace_padded_patch_marker_lines/input/file.txt +1 -0
  89. apply_patch_py-0.1.0/tests/fixtures/scenarios/020_whitespace_padded_patch_marker_lines/patch.txt +6 -0
  90. apply_patch_py-0.1.0/tests/fixtures/scenarios/021_update_file_deletion_only/expected/lines.txt +2 -0
  91. apply_patch_py-0.1.0/tests/fixtures/scenarios/021_update_file_deletion_only/input/lines.txt +3 -0
  92. apply_patch_py-0.1.0/tests/fixtures/scenarios/021_update_file_deletion_only/patch.txt +7 -0
  93. apply_patch_py-0.1.0/tests/fixtures/scenarios/022_update_file_end_of_file_marker/expected/tail.txt +2 -0
  94. apply_patch_py-0.1.0/tests/fixtures/scenarios/022_update_file_end_of_file_marker/input/tail.txt +2 -0
  95. apply_patch_py-0.1.0/tests/fixtures/scenarios/022_update_file_end_of_file_marker/patch.txt +8 -0
  96. apply_patch_py-0.1.0/tests/fixtures/scenarios/README.md +18 -0
  97. apply_patch_py-0.1.0/tests/integration/fixture/dirty_script.py +1311 -0
  98. apply_patch_py-0.1.0/tests/integration/providers.py +25 -0
  99. apply_patch_py-0.1.0/tests/integration/test_llm_providers.py +411 -0
  100. apply_patch_py-0.1.0/tests/test_cli.py +69 -0
  101. apply_patch_py-0.1.0/tests/test_scenarios.py +64 -0
  102. apply_patch_py-0.1.0/tests/test_tool.py +250 -0
  103. apply_patch_py-0.1.0/uv.lock +3074 -0
@@ -0,0 +1,29 @@
1
+ name: Linting
2
+
3
+ on:
4
+ push:
5
+ branches: ["master"]
6
+ pull_request:
7
+ branches: ["master"]
8
+
9
+ jobs:
10
+ lint:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Install uv
17
+ uses: astral-sh/setup-uv@v7
18
+ with:
19
+ enable-cache: true
20
+
21
+ - name: Set up Python
22
+ run: uv python install 3.13
23
+
24
+ - name: Install dependencies
25
+ run: uv sync
26
+
27
+ - name: Run linter
28
+ shell: bash
29
+ run: uv run -- make lint
@@ -0,0 +1,29 @@
1
+ name: Unit Testing
2
+
3
+ on:
4
+ push:
5
+ branches: ["master"]
6
+ pull_request:
7
+ branches: ["master"]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+
16
+ - name: Install uv
17
+ uses: astral-sh/setup-uv@v7
18
+ with:
19
+ enable-cache: true
20
+
21
+ - name: Set up Python
22
+ run: uv python install 3.13
23
+
24
+ - name: Install dependencies
25
+ run: uv sync
26
+
27
+ - name: Run tests
28
+ shell: bash
29
+ run: uv run -- make test
@@ -0,0 +1,12 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+ .idea
12
+ apply-patch-rust
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 marcin
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.
@@ -0,0 +1,48 @@
1
+ .PHONY: init install format lint test build publish publish-test verify-testpypi clean
2
+
3
+ PYTHON ?= uv run
4
+
5
+ DIST_DIR := dist
6
+
7
+ init:
8
+ @command -v uv >/dev/null 2>&1 || { echo >&2 "Error: uv is not installed."; exit 1; }
9
+ @command -v python >/dev/null 2>&1 || { echo >&2 "Error: python is not installed."; exit 1; }
10
+
11
+ install: init
12
+ @uv sync
13
+
14
+ format: init
15
+ @$(PYTHON) ruff check src tests --fix
16
+ @$(PYTHON) black src tests
17
+
18
+ lint: init
19
+ @$(PYTHON) ruff check src tests
20
+ @$(PYTHON) black --check src tests
21
+ @$(PYTHON) mypy src
22
+
23
+ test: init
24
+ @$(PYTHON) pytest
25
+
26
+ # Preferred build (uv)
27
+ build: init
28
+ @rm -rf $(DIST_DIR)
29
+ @uv build
30
+ @ls -la $(DIST_DIR)
31
+
32
+ # Publish to PyPI (requires UV_PUBLISH_TOKEN)
33
+ publish: build
34
+ @test -n "$$UV_PUBLISH_TOKEN" || { echo >&2 "Error: UV_PUBLISH_TOKEN is not set"; exit 1; }
35
+ @uv publish --token "$$UV_PUBLISH_TOKEN"
36
+
37
+ # Publish to TestPyPI (requires UV_PUBLISH_TOKEN)
38
+ publish-test: build
39
+ @test -n "$$UV_PUBLISH_TOKEN" || { echo >&2 "Error: UV_PUBLISH_TOKEN is not set"; exit 1; }
40
+ @uv publish --token "$$UV_PUBLISH_TOKEN" --publish-url https://test.pypi.org/legacy/
41
+
42
+ # Verify install from TestPyPI (assumes publish-test was run)
43
+ verify-testpypi:
44
+ @uv run --index-url https://test.pypi.org/simple --extra-index-url https://pypi.org/simple \
45
+ --with apply-patch-py --no-project -- python -c "import apply_patch_py"
46
+
47
+ clean:
48
+ @rm -rf $(DIST_DIR) .pytest_cache .ruff_cache .mypy_cache
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: apply-patch-py
3
+ Version: 0.1.0
4
+ Summary: A python port of codex apply patch
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: aiofiles>=24.1.0
File without changes
File without changes
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "apply-patch-py"
3
+ version = "0.1.0"
4
+ description = "A python port of codex apply patch"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "aiofiles>=24.1.0",
9
+ ]
10
+
11
+ [project.scripts]
12
+ apply-patch-py = "apply_patch_py:main"
13
+
14
+ [dependency-groups]
15
+ dev = [
16
+ "pydantic-ai>=1.44.0",
17
+ "pytest>=7.4.3,<8.0.0",
18
+ "pytest-asyncio>=0.23.8",
19
+ "black>=25.12.0",
20
+ "mypy<2.0.0,>=1.8.0",
21
+ "ruff<1.0.0,>=0.2.2",
22
+ "build>=1.0.0",
23
+ "twine>=5.0.0",
24
+ "types-aiofiles>=25.1.0.20251011",
25
+ ]
26
+
27
+ [tool.uv]
28
+ default-groups = ["dev"]
29
+
30
+ [tool.ruff]
31
+ exclude = [
32
+ "tests/integration/fixture/dirty_script.py",
33
+ ]
34
+
35
+ [build-system]
36
+ requires = ["hatchling"]
37
+ build-backend = "hatchling.build"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/apply_patch_py"]
41
+
42
+ [tool.pytest.ini_options]
43
+ asyncio_mode = "auto"
44
+ markers = [
45
+ "integration: integration test that requires external LLM provider configuration",
46
+ ]
@@ -0,0 +1,12 @@
1
+ from pathlib import Path
2
+
3
+ from .applier import PatchApplier
4
+ from .cli import main
5
+ from .models import AffectedPaths
6
+
7
+
8
+ async def apply_patch(patch_text: str, workdir: Path = Path(".")) -> AffectedPaths:
9
+ return await PatchApplier.apply(patch_text, workdir)
10
+
11
+
12
+ __all__ = ["apply_patch", "AffectedPaths", "main"]
@@ -0,0 +1,239 @@
1
+ import os
2
+ import aiofiles
3
+ from pathlib import Path
4
+ from typing import List
5
+ from .models import (
6
+ Hunk,
7
+ AddFile,
8
+ DeleteFile,
9
+ UpdateFile,
10
+ UpdateFileChunk,
11
+ AffectedPaths,
12
+ )
13
+ from .parser import PatchParser
14
+ from .search import ContentSearcher
15
+
16
+
17
+ class PatchApplier:
18
+ @classmethod
19
+ async def apply(cls, patch_text: str, workdir: Path = Path(".")) -> AffectedPaths:
20
+ try:
21
+ patch = PatchParser.parse(patch_text)
22
+ except ValueError as e:
23
+ raise RuntimeError(str(e)) from e
24
+
25
+ if not patch.hunks:
26
+ raise RuntimeError("No files were modified.")
27
+
28
+ affected = AffectedPaths()
29
+
30
+ for hunk in patch.hunks:
31
+ await cls._apply_hunk(hunk, workdir, affected)
32
+
33
+ return affected
34
+
35
+ @classmethod
36
+ async def _apply_hunk(cls, hunk: Hunk, workdir: Path, affected: AffectedPaths):
37
+ workdir = workdir.resolve()
38
+ path = workdir / hunk.path
39
+
40
+ if isinstance(hunk, AddFile):
41
+ if path.parent != workdir:
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+
44
+ async with aiofiles.open(path, "w", encoding="utf-8") as f:
45
+ await f.write(hunk.content)
46
+ affected.added.append(hunk.path)
47
+
48
+ elif isinstance(hunk, DeleteFile):
49
+ try:
50
+ os.remove(path)
51
+ except OSError as e:
52
+ raise RuntimeError(f"Failed to delete file {hunk.path}") from e
53
+
54
+ affected.deleted.append(hunk.path)
55
+
56
+ elif isinstance(hunk, UpdateFile):
57
+ try:
58
+ async with aiofiles.open(path, "r", encoding="utf-8") as f:
59
+ content = await f.read()
60
+ except FileNotFoundError as e:
61
+ raise RuntimeError(
62
+ f"Failed to read file to update {hunk.path}: No such file or directory (os error 2)"
63
+ ) from e
64
+
65
+ original_lines = content.split("\n")
66
+ if original_lines and original_lines[-1] == "":
67
+ original_lines.pop()
68
+
69
+ new_lines = cls._apply_chunks(original_lines, hunk.chunks, hunk.path)
70
+
71
+ if not new_lines or new_lines[-1] != "":
72
+ new_lines.append("")
73
+ new_content = "\n".join(new_lines)
74
+
75
+ if hunk.move_to:
76
+ dest = workdir / hunk.move_to
77
+ if dest.parent != workdir:
78
+ dest.parent.mkdir(parents=True, exist_ok=True)
79
+
80
+ async with aiofiles.open(dest, "w", encoding="utf-8") as f:
81
+ await f.write(new_content)
82
+
83
+ try:
84
+ os.remove(path)
85
+ except OSError as e:
86
+ raise RuntimeError(f"Failed to remove original {hunk.path}") from e
87
+
88
+ affected.modified.append(hunk.move_to)
89
+ else:
90
+ async with aiofiles.open(path, "w", encoding="utf-8") as f:
91
+ await f.write(new_content)
92
+ affected.modified.append(hunk.path)
93
+
94
+ @classmethod
95
+ def _apply_chunks(
96
+ cls, original_lines: List[str], chunks: List[UpdateFileChunk], path: Path
97
+ ) -> List[str]:
98
+ current_lines = list(original_lines)
99
+ line_index = 0
100
+
101
+ if not chunks:
102
+ raise RuntimeError(
103
+ f"Invalid patch: Update file hunk for path '{path}' is empty"
104
+ )
105
+
106
+ for chunk in chunks:
107
+ if chunk.change_context:
108
+ found_idx = ContentSearcher.find_sequence(
109
+ current_lines,
110
+ [chunk.change_context],
111
+ line_index,
112
+ False,
113
+ )
114
+ if found_idx is None:
115
+ raise RuntimeError(
116
+ f"Failed to find context '{chunk.change_context}' in {path}"
117
+ )
118
+ line_index = found_idx + 1
119
+
120
+ if not chunk.old_lines:
121
+ insertion_idx = len(current_lines)
122
+ if current_lines and current_lines[-1] == "":
123
+ insertion_idx -= 1
124
+ current_lines[insertion_idx:insertion_idx] = chunk.new_lines
125
+ line_index = insertion_idx + len(chunk.new_lines)
126
+ continue
127
+
128
+ pattern: List[str] = list(chunk.old_lines)
129
+ new_block: List[str] = list(chunk.new_lines)
130
+
131
+ found_idx = ContentSearcher.find_sequence(
132
+ current_lines,
133
+ pattern,
134
+ line_index,
135
+ chunk.is_end_of_file,
136
+ )
137
+
138
+ if found_idx is None and pattern and pattern[-1] == "":
139
+ pattern = pattern[:-1]
140
+ if new_block and new_block[-1] == "":
141
+ new_block = new_block[:-1]
142
+
143
+ found_idx = ContentSearcher.find_sequence(
144
+ current_lines,
145
+ pattern,
146
+ line_index,
147
+ chunk.is_end_of_file,
148
+ )
149
+
150
+ if found_idx is None and line_index > 0:
151
+ found_idx = ContentSearcher.find_sequence(
152
+ current_lines,
153
+ pattern,
154
+ 0,
155
+ chunk.is_end_of_file,
156
+ )
157
+
158
+ if found_idx is None:
159
+ found_idx = cls._fallback_find_lines_independently(
160
+ current_lines=current_lines,
161
+ pattern=pattern,
162
+ start_idx=line_index,
163
+ is_end_of_file=chunk.is_end_of_file,
164
+ )
165
+
166
+ if found_idx is None:
167
+ raise RuntimeError(
168
+ f"Failed to find expected lines in {path}:\n"
169
+ + "\n".join(chunk.old_lines)
170
+ )
171
+
172
+ match_len = len(pattern)
173
+ current_lines[found_idx : found_idx + match_len] = new_block
174
+ line_index = found_idx + len(new_block)
175
+
176
+ return current_lines
177
+
178
+ @classmethod
179
+ def _fallback_find_lines_independently(
180
+ cls,
181
+ *,
182
+ current_lines: List[str],
183
+ pattern: List[str],
184
+ start_idx: int,
185
+ is_end_of_file: bool,
186
+ ) -> int | None:
187
+ """Fallback matcher for imperfect LLM hunks.
188
+
189
+ Strict matching is attempted first. If it fails, we try to locate the edit
190
+ position using a couple of distinctive "anchor" lines from the old block.
191
+
192
+ To reduce the chance of patching the wrong location, we only accept anchors
193
+ that are unique in the file.
194
+ """
195
+
196
+ candidates: List[str] = [p for p in pattern if p.strip()]
197
+ if not candidates:
198
+ return None
199
+
200
+ anchors = candidates[:2]
201
+ if not anchors:
202
+ return None
203
+
204
+ anchor_matches: List[int] = []
205
+ for anchor in anchors:
206
+ if not cls._is_unique_line(current_lines, anchor):
207
+ return None
208
+
209
+ idx = ContentSearcher.find_sequence(
210
+ current_lines, [anchor], start_idx, is_end_of_file
211
+ )
212
+ if idx is None and start_idx > 0:
213
+ idx = ContentSearcher.find_sequence(
214
+ current_lines, [anchor], 0, is_end_of_file
215
+ )
216
+ if idx is None:
217
+ return None
218
+ anchor_matches.append(idx)
219
+
220
+ if len(anchor_matches) >= 2 and anchor_matches[1] < anchor_matches[0]:
221
+ return None
222
+
223
+ return min(anchor_matches)
224
+
225
+ @staticmethod
226
+ def _is_unique_line(lines: List[str], line: str) -> bool:
227
+ """Return True if 'line' occurs exactly once in 'lines'.
228
+
229
+ We use strict equality; if normalization is needed, it should be applied at
230
+ the search layer.
231
+ """
232
+
233
+ count = 0
234
+ for candidate in lines:
235
+ if candidate == line:
236
+ count += 1
237
+ if count > 1:
238
+ return False
239
+ return count == 1
@@ -0,0 +1,50 @@
1
+ import argparse
2
+ import sys
3
+ import asyncio
4
+ from pathlib import Path
5
+ from .applier import PatchApplier
6
+
7
+
8
+ async def run_apply_patch(patch_text: str) -> int:
9
+ try:
10
+ affected = await PatchApplier.apply(patch_text, Path("."))
11
+ print("Success. Updated the following files:")
12
+ for path in affected.added:
13
+ print(f"A {path}")
14
+ for path in affected.modified:
15
+ print(f"M {path}")
16
+ for path in affected.deleted:
17
+ print(f"D {path}")
18
+ return 0
19
+ except Exception as e:
20
+ print(f"{e}", file=sys.stderr)
21
+ return 1
22
+ except BaseException as e:
23
+ # Catch-all for non-Exception errors (excluding SystemExit which is handled by caller/Python)
24
+ if isinstance(e, SystemExit):
25
+ raise
26
+ print(f"{e}", file=sys.stderr)
27
+ return 1
28
+
29
+
30
+ def main():
31
+ parser = argparse.ArgumentParser(description="Apply a patch to files.")
32
+ parser.add_argument(
33
+ "patch", nargs="?", help="The patch content. If omitted, reads from stdin."
34
+ )
35
+
36
+ args = parser.parse_args()
37
+
38
+ if args.patch:
39
+ patch_text = args.patch
40
+ else:
41
+ if sys.stdin.isatty():
42
+ parser.print_help()
43
+ sys.exit(2)
44
+ patch_text = sys.stdin.read()
45
+
46
+ try:
47
+ sys.exit(asyncio.run(run_apply_patch(patch_text)))
48
+ except Exception as e:
49
+ print(f"Unexpected error: {e}", file=sys.stderr)
50
+ sys.exit(1)
@@ -0,0 +1,36 @@
1
+ PATCH_FORMAT_TOOL_INSTRUCTIONS = "Use this tool to edit files by applying patches. This is a FREEFORM tool, so do not wrap the patch in JSON. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope"
2
+
3
+
4
+ PATCH_FORMAT_INSTRUCTIONS = """
5
+ *** Begin Patch
6
+ [ one or more file sections ]
7
+ *** End Patch
8
+
9
+ Within that envelope, you get a sequence of file operations.
10
+ You MUST include a header to specify the action you are taking.
11
+ Each operation starts with one of three headers:
12
+
13
+ *** Add File: <path> - create a new file. Every following line is a + line (the initial contents).
14
+ *** Delete File: <path> - remove an existing file. Nothing follows.
15
+ *** Update File: <path> - patch an existing file in place (optionally with a rename).
16
+
17
+ Example patch:
18
+
19
+ ```
20
+ *** Begin Patch
21
+ *** Add File: hello.txt
22
+ +Hello world
23
+ *** Update File: src/app.py
24
+ *** Move to: src/main.py
25
+ @@ def greet():
26
+ -print("Hi")
27
+ +print("Hello, world!")
28
+ *** Delete File: obsolete.txt
29
+ *** End Patch
30
+ ```
31
+
32
+ It is important to remember:
33
+
34
+ - You must include a header with your intended action (Add/Delete/Update)
35
+ - You must prefix new lines with `+` even when creating a new file
36
+ """
@@ -0,0 +1,65 @@
1
+ from dataclasses import dataclass, field
2
+ from pathlib import Path
3
+ from typing import List, Optional
4
+
5
+
6
+ @dataclass
7
+ class UpdateFileChunk:
8
+ """
9
+ Represents a chunk of changes within a file update.
10
+ """
11
+
12
+ old_lines: List[str]
13
+ new_lines: List[str]
14
+ change_context: Optional[str] = None
15
+ is_end_of_file: bool = False
16
+
17
+
18
+ @dataclass
19
+ class Hunk:
20
+ """
21
+ Base class for a file operation in a patch.
22
+ """
23
+
24
+ path: Path
25
+
26
+
27
+ @dataclass
28
+ class AddFile(Hunk):
29
+ """
30
+ Operation to create a new file with specific content.
31
+ """
32
+
33
+ content: str
34
+
35
+
36
+ @dataclass
37
+ class DeleteFile(Hunk):
38
+ """
39
+ Operation to delete an existing file.
40
+ """
41
+
42
+ pass
43
+
44
+
45
+ @dataclass
46
+ class UpdateFile(Hunk):
47
+ """
48
+ Operation to update an existing file, optionally renaming it.
49
+ """
50
+
51
+ chunks: List[UpdateFileChunk]
52
+ move_to: Optional[Path] = None
53
+
54
+
55
+ @dataclass
56
+ class Patch:
57
+ hunks: List[Hunk]
58
+
59
+
60
+ @dataclass
61
+ class AffectedPaths:
62
+ added: List[Path] = field(default_factory=list)
63
+ modified: List[Path] = field(default_factory=list)
64
+ deleted: List[Path] = field(default_factory=list)
65
+ success: bool = True