Source code for axio_tools_local.patch_file

import asyncio
import os
import tempfile
import threading
from pathlib import Path

from axio.field import StrictStr

# Tracks absolute paths patched since the last read_file call for that path.
# Cleared per-path when read_file reads the file; prevents double-patching
# with stale line numbers within a single agent turn.
_patched_files: set[str] = set()
_patched_files_lock = threading.Lock()


def _mark_patched(path: str) -> None:
    with _patched_files_lock:
        _patched_files.add(path)


def _check_and_mark(path: str) -> None:
    with _patched_files_lock:
        if path in _patched_files:
            raise RuntimeError(
                f"{path!r} was already patched since the last read. "
                "Re-read the file with line_numbers=True to get updated line numbers before patching again."
            )
        _patched_files.add(path)


def clear_patched(path: str) -> None:
    """Remove path from the patched-files tracker. Called by read_file."""
    with _patched_files_lock:
        _patched_files.discard(path)


[docs] async def patch_file( file_path: StrictStr, from_line: int, to_line: int, content: str, mode: int = 0o644, ) -> str: """Replace a range of lines in an existing file. Lines are 1-indexed: from_line and to_line are both inclusive (from_line=2, to_line=4 replaces lines 2, 3, 4). To insert without deleting, set to_line = from_line - 1. Always read the file first with line_numbers=True to get correct line numbers. Patch each file at most once per read — re-read with line_numbers=True after patching before issuing another patch to the same file. Use this for surgical edits instead of rewriting the whole file with write_file.""" def _blocking() -> str: # Resolve symlinks so reads and writes go to the real file. path = (Path(os.getcwd()) / file_path).resolve() if not path.is_file(): raise FileNotFoundError(f"{file_path} is not a valid file") resolved = str(path) _check_and_mark(resolved) try: with path.open("r") as f: lines = f.readlines() n = len(lines) if not (1 <= from_line <= n + 1): raise ValueError(f"from_line={from_line} out of range; file has {n} lines (valid: 1..{n + 1})") if not (from_line - 1 <= to_line <= n): raise ValueError(f"to_line={to_line} out of range (valid: {from_line - 1}..{n})") content_lines = content.splitlines(keepends=True) if content_lines and not content_lines[-1].endswith("\n"): content_lines[-1] += "\n" new_lines = lines[: from_line - 1] + content_lines + lines[to_line:] content_str = "".join(new_lines) fd, tmp_path_str = tempfile.mkstemp(dir=path.parent) try: with os.fdopen(fd, "w") as f: f.write(content_str) byte_count = f.tell() os.chmod(tmp_path_str, mode) os.replace(tmp_path_str, path) except Exception: try: os.unlink(tmp_path_str) except OSError: pass raise except Exception: # Roll back the "patched" marker so the caller can still read and retry. clear_patched(resolved) raise return f"{byte_count} bytes written to {file_path}" return await asyncio.to_thread(_blocking)