User Tools

Site Tools


python:gpt-patcher

GPT Patcher

ChatGPT can provide a unified patch as output for code related changes (project instructions or prompt example 'return diffs of the changes proposed for ease of applying them.'). These unified patches are in a format not understood by git or the patch command. They do not contain specific line numbers but context surrounding the changes. To avoid issues with GPT hallucinations, strict checks need to be performed.

The script below can help apply these patches.
It accepts file, stdin, clipboard or interactive manual paste as source for the patch, parses it for the target filename or prompts if none found, checks if it can apply the patch and outputs the newly patched file.
Command line option -i can replace the original file immediately, but creates backup files.
Command line option -t can be used to patch a different filename.
On errors, the script will abort.

gpt-patcher.py
#!/usr/bin/env python3
import argparse
import os
import re
import sys
import shutil
 
try:
    import pyperclip
except ImportError:
    pyperclip = None
 
 
PATCH_BEGIN = "*** Begin Patch"
PATCH_END = "*** End Patch"
PATCH_UPDATE_FILE = "*** Update File:"
 
 
def read_patch_input(source: str = None):
    """Read patch from file, stdin, clipboard, or prompt."""
    if source:
        with open(source, "r", encoding="utf-8") as f:
            data = f.read()
    elif not sys.stdin.isatty():
        data = sys.stdin.read()
    else:
        clip = pyperclip.paste().strip() if pyperclip else ""
        if clip:
            print("Using patch from clipboard.")
            data = clip
        else:
            if pyperclip:
                print("No valid patch detected in clipboard.")
            print("Please paste your patch below, then press Ctrl-D (Linux/macOS) or Ctrl-Z (Windows) when done:\n")
            try:
                data = sys.stdin.read()
            except KeyboardInterrupt:
                sys.exit("\nAborted.")
    return data.strip()
 
 
def detect_patch_format(data: str):
    """Return (has_header, has_update_line) flags."""
    has_header = PATCH_BEGIN in data and PATCH_END in data
    has_update_line = PATCH_UPDATE_FILE in data
    return has_header, has_update_line
 
 
def parse_patch(data: str, filename_override=None):
    """Extract the target filename and list of hunks."""
    match = re.search(r"\*\*\* Update File:\s*(.+)", data)
    filename = match.group(1).strip() if match else filename_override
 
    if not filename:
        filename = input("No target file specified. Please enter the filename to patch: ").strip()
        if not filename:
            sys.exit("Error: Target filename required.")
 
    # Extract hunks (between @@ markers)
    hunks = []
    current_hunk = []
    in_hunk = False
    for line in data.splitlines():
        if line.startswith("@@"):
            if current_hunk:
                hunks.append(current_hunk)
            current_hunk = []
            in_hunk = True
        elif in_hunk:
            current_hunk.append(line)
    if current_hunk:
        hunks.append(current_hunk)
    return filename, hunks
 
 
def make_backup(path: str):
    """Create sequential .bak backups if needed."""
    base_backup = path + ".bak"
    if not os.path.exists(base_backup):
        shutil.copy2(path, base_backup)
        return base_backup
 
    i = 1
    while True:
        backup_name = f"{base_backup}{i}"
        if not os.path.exists(backup_name):
            shutil.copy2(path, backup_name)
            return backup_name
        i += 1
 
 
def find_and_replace_block(content, old_block, new_block, context_before=None, context_after=None, filename=None):
    """Find a full old_block with optional context and replace with new_block."""
    pattern_parts = []
    if context_before:
        pattern_parts.append(re.escape(context_before))
    pattern_parts.append(re.escape(old_block))
    if context_after:
        pattern_parts.append(re.escape(context_after))
 
    pattern = "(?s)" + ".*?".join(pattern_parts)
    match = re.search(pattern, content)
    if not match:
        # Try looser match
        match = re.search(re.escape(old_block), content)
        if not match:
            sys.exit(
                f"Error: Could not find patch target block in '{filename}' containing:\n{old_block.strip()[:200]}"
            )
 
    start, end = match.span()
    return content[:start] + content[start:end].replace(old_block, new_block, 1) + content[end:]
 
 
def apply_hunk_to_content(content: str, hunk, filename):
    """Apply a hunk (multi-line context aware)."""
    minus_lines, plus_lines, context_lines = [], [], []
 
    for line in hunk:
        if line.startswith("-"):
            minus_lines.append(line[1:])
        elif line.startswith("+"):
            plus_lines.append(line[1:])
        elif line.startswith(" "):
            context_lines.append(line[1:])
 
    old_block = "\n".join(minus_lines)
    new_block = "\n".join(plus_lines)
    context_before = "\n".join(context_lines[:3]) if context_lines else None
    context_after = "\n".join(context_lines[-3:]) if context_lines else None
 
    return find_and_replace_block(content, old_block, new_block, context_before, context_after, filename)
 
 
def apply_patch_to_file(filename, hunks):
    """Apply patch hunks to file contents."""
    with open(filename, "r", encoding="utf-8") as f:
        content = f.read()
 
    for hunk in hunks:
        content = apply_hunk_to_content(content, hunk, filename)
 
    return content
 
 
def main():
    parser = argparse.ArgumentParser(description="Apply ChatGPT or raw unified diffs to files.")
    parser.add_argument("patchfile", nargs="?", help="File containing the patch (optional)")
    parser.add_argument("-i", "--in-place", action="store_true", help="Replace original file (create backup)")
    parser.add_argument("-t", "--target", help="Apply patch to this file instead of the one mentioned in patch")
 
    args = parser.parse_args()
 
    data = read_patch_input(args.patchfile)
    has_header, has_update_line = detect_patch_format(data)
 
    # If header present but update line missing, prompt for file
    patch_file, hunks = parse_patch(data, filename_override=args.target)
 
    if not os.path.exists(patch_file):
        sys.exit(f"Error: Target file '{patch_file}' not found.")
 
    result = apply_patch_to_file(patch_file, hunks)
 
    if args.in_place:
        backup = make_backup(patch_file)
        with open(patch_file, "w", encoding="utf-8") as f:
            f.write(result)
        print(f"Patched {patch_file} (backup: {backup})")
    else:
        sys.stdout.write(result)
 
 
if __name__ == "__main__":
    main()
python/gpt-patcher.txt · Last modified: by Wuff