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.
- apply_patch_py-0.1.0/.github/workflows/lint.yml +29 -0
- apply_patch_py-0.1.0/.github/workflows/test.yml +29 -0
- apply_patch_py-0.1.0/.gitignore +12 -0
- apply_patch_py-0.1.0/.python-version +1 -0
- apply_patch_py-0.1.0/LICENSE +21 -0
- apply_patch_py-0.1.0/Makefile +48 -0
- apply_patch_py-0.1.0/PKG-INFO +7 -0
- apply_patch_py-0.1.0/README.md +0 -0
- apply_patch_py-0.1.0/main.py +0 -0
- apply_patch_py-0.1.0/pyproject.toml +46 -0
- apply_patch_py-0.1.0/src/apply_patch_py/__init__.py +12 -0
- apply_patch_py-0.1.0/src/apply_patch_py/applier.py +239 -0
- apply_patch_py-0.1.0/src/apply_patch_py/cli.py +50 -0
- apply_patch_py-0.1.0/src/apply_patch_py/constants.py +36 -0
- apply_patch_py-0.1.0/src/apply_patch_py/models.py +65 -0
- apply_patch_py-0.1.0/src/apply_patch_py/parser.py +249 -0
- apply_patch_py-0.1.0/src/apply_patch_py/search.py +94 -0
- apply_patch_py-0.1.0/src/apply_patch_py/utils.py +9 -0
- apply_patch_py-0.1.0/tests/conftest.py +13 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/.gitattributes +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/001_add_file/expected/bar.md +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/001_add_file/patch.txt +4 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/002_multiple_operations/expected/modify.txt +2 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/002_multiple_operations/expected/nested/new.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/002_multiple_operations/input/delete.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/002_multiple_operations/input/modify.txt +2 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/002_multiple_operations/patch.txt +9 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/003_multiple_chunks/expected/multi.txt +4 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/003_multiple_chunks/input/multi.txt +4 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/003_multiple_chunks/patch.txt +9 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/004_move_to_new_directory/expected/old/other.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/004_move_to_new_directory/expected/renamed/dir/name.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/004_move_to_new_directory/input/old/name.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/004_move_to_new_directory/input/old/other.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/004_move_to_new_directory/patch.txt +7 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/005_rejects_empty_patch/expected/foo.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/005_rejects_empty_patch/input/foo.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/005_rejects_empty_patch/patch.txt +2 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/006_rejects_missing_context/expected/modify.txt +2 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/006_rejects_missing_context/input/modify.txt +2 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/006_rejects_missing_context/patch.txt +6 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/007_rejects_missing_file_delete/expected/foo.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/007_rejects_missing_file_delete/input/foo.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/007_rejects_missing_file_delete/patch.txt +3 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/008_rejects_empty_update_hunk/expected/foo.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/008_rejects_empty_update_hunk/input/foo.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/008_rejects_empty_update_hunk/patch.txt +3 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/009_requires_existing_file_for_update/expected/foo.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/009_requires_existing_file_for_update/input/foo.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/009_requires_existing_file_for_update/patch.txt +6 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/old/other.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/010_move_overwrites_existing_destination/expected/renamed/dir/name.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/old/name.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/old/other.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/010_move_overwrites_existing_destination/input/renamed/dir/name.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/010_move_overwrites_existing_destination/patch.txt +7 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/011_add_overwrites_existing_file/expected/duplicate.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/011_add_overwrites_existing_file/input/duplicate.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/011_add_overwrites_existing_file/patch.txt +4 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/012_delete_directory_fails/expected/dir/foo.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/012_delete_directory_fails/input/dir/foo.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/012_delete_directory_fails/patch.txt +3 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/013_rejects_invalid_hunk_header/expected/foo.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/013_rejects_invalid_hunk_header/input/foo.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/013_rejects_invalid_hunk_header/patch.txt +3 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/expected/no_newline.txt +2 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/input/no_newline.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/014_update_file_appends_trailing_newline/patch.txt +7 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/015_failure_after_partial_success_leaves_changes/expected/created.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/015_failure_after_partial_success_leaves_changes/patch.txt +8 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/016_pure_addition_update_chunk/expected/input.txt +4 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/016_pure_addition_update_chunk/input/input.txt +2 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/016_pure_addition_update_chunk/patch.txt +6 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/expected/foo.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/input/foo.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/017_whitespace_padded_hunk_header/patch.txt +6 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/expected/file.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/input/file.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/018_whitespace_padded_patch_markers/patch.txt +6 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/019_unicode_simple/expected/foo.txt +3 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/019_unicode_simple/input/foo.txt +3 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/019_unicode_simple/patch.txt +7 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/020_delete_file_success/expected/keep.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/020_delete_file_success/input/keep.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/020_delete_file_success/input/obsolete.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/020_delete_file_success/patch.txt +3 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/020_whitespace_padded_patch_marker_lines/expected/file.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/020_whitespace_padded_patch_marker_lines/input/file.txt +1 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/020_whitespace_padded_patch_marker_lines/patch.txt +6 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/021_update_file_deletion_only/expected/lines.txt +2 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/021_update_file_deletion_only/input/lines.txt +3 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/021_update_file_deletion_only/patch.txt +7 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/022_update_file_end_of_file_marker/expected/tail.txt +2 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/022_update_file_end_of_file_marker/input/tail.txt +2 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/022_update_file_end_of_file_marker/patch.txt +8 -0
- apply_patch_py-0.1.0/tests/fixtures/scenarios/README.md +18 -0
- apply_patch_py-0.1.0/tests/integration/fixture/dirty_script.py +1311 -0
- apply_patch_py-0.1.0/tests/integration/providers.py +25 -0
- apply_patch_py-0.1.0/tests/integration/test_llm_providers.py +411 -0
- apply_patch_py-0.1.0/tests/test_cli.py +69 -0
- apply_patch_py-0.1.0/tests/test_scenarios.py +64 -0
- apply_patch_py-0.1.0/tests/test_tool.py +250 -0
- 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 @@
|
|
|
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
|
|
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
|