PCART  v1.4
Automated Repair of Python API Parameter Compatibility Issues
patch.py
Go to the documentation of this file.
1 
9 
10 
11 
12 import ast
13 import difflib
14 import io
15 import ntpath
16 import os
17 import shutil
18 import tokenize
19 
20 
21 
22 
29 def makePatchEdit(record,fixedAPI,repairStatus):
30  if not isinstance(fixedAPI,str) or repairStatus not in ('Successful','Unknown','Failed'):
31  return None
32  try:
33  original=ast.parse(record['call_text'],mode='eval').body
34  fixed=ast.parse(fixedAPI,mode='eval').body
35  except (KeyError,SyntaxError,ValueError):
36  return None
37  if not isinstance(original,ast.Call) or not isinstance(fixed,ast.Call):
38  return None
39  if ast.dump(original)==ast.dump(fixed):
40  return None
41  edit={key:record.get(key) for key in (
42  'id','rel_path','lineno','col_offset','end_lineno','end_col_offset','call_text',
43  )}
44  edit['fixed_api']=fixedAPI
45  edit['repair_status']=repairStatus
46  return edit
47 
48 
49 
50 
58 def applyEdits(sourceBytes,edits):
59  errLst=[]
60  try:
61  source=sourceBytes.decode('utf-8-sig')
62  root=ast.parse(source)
63  lines=source.splitlines(keepends=True)
64  byteLines=sourceBytes.splitlines(keepends=True)
65  lineOffsets=[]
66  offset=0
67  for line in byteLines:
68  lineOffsets.append(offset)
69  offset+=len(line)
70  if sourceBytes.startswith(b'\xef\xbb\xbf'):
71  lineOffsets[0]+=3
72  callNodes={(node.lineno,node.col_offset,node.end_lineno,node.end_col_offset):node
73  for node in ast.walk(root) if isinstance(node,ast.Call)}
74  commentOffsets=[]
75  for token in tokenize.generate_tokens(io.StringIO(source).readline):
76  if token.type==tokenize.COMMENT:
77  line,column=token.start
78  commentOffsets.append(lineOffsets[line-1]+len(lines[line-1][:column].encode('utf-8')))
79  except (UnicodeError,SyntaxError,ValueError,tokenize.TokenError) as e:
80  return sourceBytes,[f'Patch skipped: cannot parse source: {e}\n']
81 
82  candidates=[]
83  for edit in edits:
84  callId=edit.get('id','unknown')
85  try:
86  span=tuple(edit.get(key) for key in ('lineno','col_offset','end_lineno','end_col_offset'))
87  if span not in callNodes:
88  raise ValueError('missing or invalid callsite span')
89  original=ast.parse(edit['call_text'],mode='eval').body
90  if ast.dump(original)!=ast.dump(callNodes[span]):
91  raise ValueError('original call does not match the source span')
92  fixed=ast.parse(edit['fixed_api'],mode='eval').body
93  if not isinstance(fixed,ast.Call):
94  raise ValueError('repair result is not a single call expression')
95  start=lineOffsets[span[0]-1]+span[1]
96  end=lineOffsets[span[2]-1]+span[3]
97  if any(start<=offset<end for offset in commentOffsets):
98  raise ValueError('callsite contains comments that replacement would remove')
99  candidates.append((start,end,edit))
100  except (KeyError,TypeError,SyntaxError,ValueError) as e:
101  errLst.append(f'Patch skipped for {callId}: {e}\n')
102 
103  candidates.sort(key=lambda item:item[0])
104  overlaps=set()
105  for index,(start,end,edit) in enumerate(candidates):
106  for other in range(index+1,len(candidates)):
107  if candidates[other][0]>=end:
108  break
109  overlaps.update((index,other))
110  modified=sourceBytes
111  newline='\r\n' if b'\r\n' in sourceBytes else '\n'
112  for index in range(len(candidates)-1,-1,-1):
113  start,end,edit=candidates[index]
114  if index in overlaps:
115  errLst.append(f"Patch skipped for {edit['id']}: overlapping callsite spans\n")
116  continue
117  replacement=edit['fixed_api'].replace('\r\n','\n').replace('\n',newline).encode('utf-8')
118  modified=modified[:start]+replacement+modified[end:]
119  try:
120  ast.parse(modified.decode('utf-8-sig'))
121  except (SyntaxError,ValueError) as e:
122  errLst.append(f'Patch skipped: modified file has invalid syntax: {e}\n')
123  return sourceBytes,errLst
124  return modified,errLst
125 
126 
127 
128 
138 def writeRepairArtifacts(projPath,projName,edits,reportDir):
139  errLst=[]
140  projPath=os.path.realpath(projPath)
141  reportDir=os.path.realpath(reportDir)
142  try:
143  if os.path.commonpath((projPath,reportDir))==projPath:
144  return ['Patch skipped: report directory is inside the original project\n']
145  except ValueError:
146  pass #Windows的项目和报告允许位于不同盘符
147  patchDir=os.path.join(reportDir,'patches')
148  fixedDir=os.path.join(reportDir,'fixed_project')
149  try:
150  os.makedirs(patchDir,exist_ok=True)
151  os.makedirs(fixedDir,exist_ok=True)
152  except OSError as e:
153  return [f'Patch output failed: {e}\n']
154 
155  fileEdits={}
156  for edit in edits:
157  try:
158  relativePath=edit['rel_path'].replace('\\','/')
159  if ntpath.isabs(relativePath) or ntpath.splitdrive(relativePath)[0] \
160  or any(part in ('','..','.') for part in relativePath.split('/')) \
161  or any(char in relativePath for char in '\t\r\n'):
162  raise ValueError('invalid project-relative path')
163  file=os.path.join(projPath,*relativePath.split('/'))
164  if os.path.commonpath((projPath,os.path.realpath(file)))!=projPath:
165  raise ValueError('source file is outside the original project')
166  if os.path.realpath(file)!=os.path.abspath(file):
167  raise ValueError('source file uses a symbolic link')
168  status=edit['repair_status']
169  if status not in ('Successful','Unknown','Failed'):
170  raise ValueError('invalid repair status')
171  fileEdits.setdefault(relativePath,{}).setdefault(status,[]).append(edit)
172  except (KeyError,TypeError,AttributeError,ValueError) as e:
173  errLst.append(f"Patch skipped for {edit.get('id','unknown')}: {e}\n")
174 
175  patches={status:[] for status in ('Successful','Unknown','Failed')}
176  successfulFiles={}
177  for relativePath in sorted(fileEdits):
178  file=os.path.join(projPath,*relativePath.split('/'))
179  try:
180  with open(file,'rb') as fr:
181  originalBytes=fr.read()
182  for status,categoryEdits in fileEdits[relativePath].items():
183  modifiedBytes,errors=applyEdits(originalBytes,categoryEdits)
184  errLst.extend(f'{relativePath} <{status}>: {error}' for error in errors)
185  if modifiedBytes==originalBytes:
186  continue
187  #保留diff内容行的原始换行;无末尾换行时添加git补丁标记
188  diff=difflib.unified_diff(originalBytes.decode('utf-8').splitlines(keepends=True),modifiedBytes.decode('utf-8').splitlines(keepends=True),fromfile=f'a/{relativePath}\t',tofile=f'b/{relativePath}\t')
189  patch=[]
190  for line in diff:
191  patch.append(line if line.endswith('\n') else line+'\n\\ No newline at end of file\n')
192  patches[status].extend(patch)
193  if status=='Successful':
194  successfulFiles[relativePath]=modifiedBytes
195  except (OSError,UnicodeError,ValueError) as e:
196  errLst.append(f'Patch skipped for {relativePath}: {e}\n')
197 
198  if successfulFiles:
199  try:
200  #不跟随项目中的链接,避免把项目外部内容复制进输出
201  fixedProject=os.path.join(fixedDir,projName)
202  shutil.copytree(projPath,fixedProject,ignore=lambda directory,names:[
203  name for name in names if os.path.islink(os.path.join(directory,name))
204  ])
205  for relativePath,modifiedBytes in successfulFiles.items():
206  with open(os.path.join(fixedProject,*relativePath.split('/')),'wb') as fw:
207  fw.write(modifiedBytes)
208  except OSError as e:
209  errLst.append(f'Fixed project output failed: {e}\n')
210 
211  for status,patch in patches.items():
212  if patch:
213  try:
214  with open(os.path.join(patchDir,f'{status.lower()}.patch'),'wb') as fw:
215  fw.write(''.join(patch).encode('utf-8'))
216  except OSError as e:
217  errLst.append(f'Patch output failed for {status}: {e}\n')
218  return errLst
def makePatchEdit(record, fixedAPI, repairStatus)
Collect one unambiguous repair result with its original source span 收集唯一修复表达式及其原始源码范围
Definition: patch.py:29
def writeRepairArtifacts(projPath, projName, edits, reportDir)
Write independent category patches and a Successful-only project copy 写出三类独立补丁及仅含Successful修复的项目副本
Definition: patch.py:138
def applyEdits(sourceBytes, edits)
Apply non-overlapping edits using UTF-8 byte offsets from the original AST 根据原始AST的UTF-8字节位置应用不重叠的修复
Definition: patch.py:58