mirror of
https://github.com/hanzoai/aci.git
synced 2026-11-02 19:07:28 +00:00
292 lines
10 KiB
Python
292 lines
10 KiB
Python
import difflib
|
|
import glob
|
|
import logging
|
|
import os
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from .codeblocks import get_parser_by_path
|
|
from .codeblocks.codeblocks import CodeBlockType, CodeBlockTypeGroup
|
|
from .codeblocks.module import Module
|
|
from .codeblocks.parser.python import PythonParser
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class UpdateResult:
|
|
file_path: str
|
|
updated: bool
|
|
diff: Optional[str] = None
|
|
error: Optional[str] = None
|
|
new_span_ids: Optional[set[str]] = None
|
|
|
|
|
|
class CodeFile(BaseModel):
|
|
file_path: str
|
|
content: str
|
|
module: Optional[Module] = None
|
|
|
|
dirty: bool = False
|
|
|
|
@classmethod
|
|
def from_file(cls, repo_path: str, file_path: str):
|
|
with open(os.path.join(repo_path, file_path), 'r') as f:
|
|
parser = get_parser_by_path(file_path)
|
|
if parser:
|
|
content = f.read()
|
|
module = parser.parse(content)
|
|
else:
|
|
module = None
|
|
return cls(file_path=file_path, content=content, module=module)
|
|
|
|
@classmethod
|
|
def from_content(cls, file_path: str, content: str):
|
|
parser = PythonParser()
|
|
module = parser.parse(content)
|
|
return cls(file_path=file_path, content=content, module=module)
|
|
|
|
@property
|
|
def supports_codeblocks(self):
|
|
return self.module is not None
|
|
|
|
def update_content_by_line_numbers(
|
|
self, start_line_index: int, end_line_index: int, replacement_content: str
|
|
) -> UpdateResult:
|
|
replacement_lines = replacement_content.split('\n')
|
|
|
|
# Strip empty lines from the start and end
|
|
while replacement_lines and replacement_lines[0].strip() == '':
|
|
replacement_lines.pop(0)
|
|
|
|
while replacement_lines and replacement_lines[-1].strip() == '':
|
|
replacement_lines.pop()
|
|
|
|
original_lines = self.content.split('\n')
|
|
|
|
replacement_lines = remove_duplicate_lines(
|
|
replacement_lines, original_lines[end_line_index:]
|
|
)
|
|
|
|
updated_lines = (
|
|
original_lines[:start_line_index]
|
|
+ replacement_lines
|
|
+ original_lines[end_line_index:]
|
|
)
|
|
updated_content = '\n'.join(updated_lines)
|
|
logger.info(
|
|
f'Updating content for {self.file_path} from line {start_line_index} to {end_line_index} with {len(replacement_lines)} lines. The updated file has {len(updated_lines)} lines.'
|
|
)
|
|
|
|
return self.update_content(updated_content)
|
|
|
|
def update_content(self, updated_content: str) -> UpdateResult:
|
|
diff = do_diff(self.file_path, self.content, updated_content)
|
|
if diff:
|
|
parser = get_parser_by_path(self.file_path)
|
|
if parser:
|
|
module = parser.parse(updated_content)
|
|
if not module.children:
|
|
return UpdateResult(
|
|
file_path=self.file_path,
|
|
updated=False,
|
|
diff=diff,
|
|
error='The updated code is invalid.',
|
|
)
|
|
|
|
# TODO: Move the prompt instructions to the loop
|
|
error_blocks = module.find_errors()
|
|
validation_errors = module.find_validation_errors()
|
|
existing_placeholders = self.module.find_blocks_with_type(
|
|
CodeBlockType.COMMENTED_OUT_CODE
|
|
)
|
|
new_placeholders = (
|
|
module.find_blocks_with_type(CodeBlockType.COMMENTED_OUT_CODE)
|
|
if not existing_placeholders
|
|
else []
|
|
)
|
|
if error_blocks or validation_errors or new_placeholders:
|
|
error_response = ''
|
|
if error_blocks:
|
|
for error_block in error_blocks:
|
|
parent_block = error_block.find_type_group_in_parents(
|
|
CodeBlockTypeGroup.STRUCTURE
|
|
)
|
|
if (
|
|
parent_block
|
|
and not parent_block.type == CodeBlockType.MODULE
|
|
):
|
|
error_response += f'{parent_block.type.name} has invalid code:\n\n```{parent_block.to_string()}\n```.\n'
|
|
else:
|
|
error_response += f'This code is invalid: \n```{error_block.to_string()}\n```.\n'
|
|
|
|
if new_placeholders:
|
|
for new_placeholder in new_placeholders:
|
|
parent_block = new_placeholder.find_type_group_in_parents(
|
|
CodeBlockTypeGroup.STRUCTURE
|
|
)
|
|
if parent_block:
|
|
error_response += f"{parent_block.identifier} has a placeholder `{new_placeholder.content}` indicating that it's not fully implemented. Implement the full {parent_block.type.name} or reject the request.: \n\n```{parent_block.to_string()}```\n\n"
|
|
else:
|
|
error_response += f'There is a placeholder indicating out commented code : \n```{new_placeholder.to_string()}\n```. Do the full implementation or reject the request.\n'
|
|
|
|
for validation_error in validation_errors:
|
|
error_response += f'{validation_error}\n'
|
|
|
|
logger.warning(
|
|
f'Errors in updated file {self.file_path}:\n{error_response}'
|
|
)
|
|
|
|
return UpdateResult(
|
|
file_path=self.file_path,
|
|
updated=False,
|
|
diff=diff,
|
|
error=error_response,
|
|
)
|
|
|
|
new_span_ids = module.get_all_span_ids() - set(
|
|
self.module.get_all_span_ids()
|
|
)
|
|
|
|
logger.info(
|
|
f'Updated content for {self.file_path} with {len(new_span_ids)} new span ids.'
|
|
)
|
|
self.module = module
|
|
else:
|
|
new_span_ids = []
|
|
|
|
self.dirty = True
|
|
self.content = updated_content
|
|
|
|
return UpdateResult(
|
|
file_path=self.file_path,
|
|
updated=True,
|
|
diff=diff,
|
|
new_span_ids=new_span_ids,
|
|
)
|
|
|
|
return UpdateResult(file_path=self.file_path, updated=False)
|
|
|
|
|
|
class FileRepository:
|
|
def __init__(self, repo_path: str):
|
|
self._repo_path = repo_path
|
|
self._files: dict[str, CodeFile] = {}
|
|
|
|
@property
|
|
def path(self):
|
|
return self._repo_path
|
|
|
|
def get_file(
|
|
self, file_path: str, refresh: bool = False, from_origin: bool = False
|
|
):
|
|
"""
|
|
Get a file from the repository.
|
|
Args:
|
|
"""
|
|
file = self._files.get(file_path)
|
|
if not file or refresh or from_origin:
|
|
full_file_path = os.path.join(self._repo_path, file_path)
|
|
if not os.path.exists(full_file_path):
|
|
logger.warning(f'File not found: {full_file_path}')
|
|
return None
|
|
if not os.path.isfile(full_file_path):
|
|
logger.warning(f'{full_file_path} is not a file')
|
|
return None
|
|
|
|
with open(full_file_path, 'r') as f:
|
|
parser = get_parser_by_path(file_path)
|
|
if parser:
|
|
content = f.read()
|
|
module = parser.parse(content, file_path)
|
|
file = CodeFile(file_path=file_path, content=content, module=module)
|
|
else:
|
|
file = CodeFile(file_path=file_path, content=f.read())
|
|
|
|
if refresh or not from_origin:
|
|
self._files[file_path] = file
|
|
return file
|
|
|
|
def save_file(self, file_path: str, updated_content: Optional[str] = None):
|
|
file = self._files.get(file_path)
|
|
full_file_path = os.path.join(self._repo_path, file_path)
|
|
with open(full_file_path, 'w') as f:
|
|
updated_content = updated_content or file.module.to_string()
|
|
f.write(updated_content)
|
|
|
|
file.dirty = False
|
|
|
|
def save(self):
|
|
for file in self._files.values():
|
|
if file.dirty:
|
|
self.save_file(file.file_path, file.content)
|
|
|
|
def matching_files(self, file_pattern: str):
|
|
matched_files = []
|
|
for matched_file in glob.iglob(
|
|
file_pattern, root_dir=self._repo_path, recursive=True
|
|
):
|
|
matched_files.append(matched_file)
|
|
|
|
if not matched_files and not file_pattern.startswith('*'):
|
|
return self.matching_files(f'**/{file_pattern}')
|
|
|
|
return matched_files
|
|
|
|
def find_files(self, file_patterns: list[str]) -> set[str]:
|
|
found_files = set()
|
|
for file_pattern in file_patterns:
|
|
matched_files = self.matching_files(file_pattern)
|
|
found_files.update(matched_files)
|
|
|
|
return found_files
|
|
|
|
def has_matching_files(self, file_pattern: str):
|
|
for matched_file in glob.iglob(
|
|
file_pattern, root_dir=self._repo_path, recursive=True
|
|
):
|
|
return True
|
|
return False
|
|
|
|
def file_match(self, file_pattern: str, file_path: str):
|
|
match = False
|
|
for matched_file in glob.iglob(
|
|
file_pattern, root_dir=self._repo_path, recursive=True
|
|
):
|
|
if matched_file == file_path:
|
|
match = True
|
|
break
|
|
return match
|
|
|
|
|
|
def remove_duplicate_lines(replacement_lines, original_lines):
|
|
"""
|
|
Removes overlapping lines at the end of replacement_lines that match the beginning of original_lines.
|
|
"""
|
|
if not replacement_lines or not original_lines:
|
|
return replacement_lines
|
|
|
|
max_overlap = min(len(replacement_lines), len(original_lines))
|
|
|
|
for overlap in range(max_overlap, 0, -1):
|
|
if replacement_lines[-overlap:] == original_lines[:overlap]:
|
|
return replacement_lines[:-overlap]
|
|
|
|
return replacement_lines
|
|
|
|
|
|
def do_diff(
|
|
file_path: str, original_content: str, updated_content: str
|
|
) -> Optional[str]:
|
|
return ''.join(
|
|
difflib.unified_diff(
|
|
original_content.strip().splitlines(True),
|
|
updated_content.strip().splitlines(True),
|
|
fromfile=file_path,
|
|
tofile=file_path,
|
|
lineterm='\n',
|
|
)
|
|
)
|