-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathutils.py
More file actions
1862 lines (1499 loc) · 76.9 KB
/
Copy pathutils.py
File metadata and controls
1862 lines (1499 loc) · 76.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Note: Many of the functions in this file are no longer used, we will clean them up soon.
import re
import subprocess
import os
import openai
import tokenize
from collections import defaultdict
from io import BytesIO
import ast
import difflib
from groq import Groq
import json
def get_api_key(key_name, config_file="keys.json"):
# Try environment variable first
key = os.environ.get(key_name)
if key:
return key
# Fallback: try JSON config
if os.path.isfile(config_file):
with open(config_file) as f:
config = json.load(f)
return config.get(key_name)
OPENAI_API_KEY = get_api_key("OPENAI_API_KEY")
openai.api_key = OPENAI_API_KEY
def extract_edited_files(diff_content):
"""
Extract the filenames of all edited files from a unified diff.
Parameters:
diff_content (str): The unified diff content as a string.
Returns:
list: A list of relative paths of the edited files.
"""
# Regular expression to capture the file paths from `+++ b/` lines
matches = re.findall(r'^\+\+\+ b/(.+)$', diff_content, re.MULTILINE)
return matches
def get_golden_file(retrieved_funcs, func_in_golden_patch):
"""
- func_in_golden_patch is the name of the golden file
- retrieved_funcs is a string containing the code for all the BM25-retrieved functions
This function extracts and returns the code of the golden file
"""
t = retrieved_funcs.split(func_in_golden_patch)
if len(t) == 1:
return "" # func in golden patch was not retrieved
else:
return t[1][2:-9] # trim some garbage in the beginning and end
def add_line_numbers(code):
"""Input (String):
x = 1
print(x)
Output (String):
1 x = 1
2 print(x)
"""
code_lines = code.splitlines()
code_with_line_nums = []
for (i,line) in enumerate(code_lines):
code_with_line_nums.append(f"{i+1} {line}")
return "\n".join(code_with_line_nums)
def build_prompt(
row,
include_issue_description=False,
include_golden_code=False,
sliced=False,
include_issue_comments=False,
include_pr_desc=False,
include_predicted_test_file=False,
include_sbst_test_file=False,
):
golden_patch = row['patch']
cot_text = ""
predicted_test_file_text = ""
predicted_test_file_contents = ""
task = "Your task is to write a test function that fails before the changes in the <patch> and passes after the changes in the <patch>, hence verifying that the <patch> resolves the <issue>. "
task2 = "Generate a test function that checks whether the <patch> resolves the <issue>.\n"
task3 = ". The test function should be self-contained and to-the-point. "
predicted_test_file_text = ""
predicted_test_file_contents = ""
if include_predicted_test_file:
test_code = row["predicted_test_file_content_sliced"]
test_code = "\n".join(test_code.splitlines()[:200]) # keep the prompt from exploding. We also tried keeping the 3 first tests in the prompt with equivalent performance
predicted_test_file_text += "Your generated test file will then be manually inserted by us in the test file shown in the <test_file> brackets; you can use the contents in these brackets for motivation if needed. "
predicted_test_file_contents += "<test_file>\n%s\n</test_file>\n\n" % test_code
task3 = ", or at most you can include a decorator to parameterize the test inputs, if one is used by the a test in <test_file> from which you drew motivation (if any). The test function should be self-contained (e.g., no parameters unless a decorator is used to parameterize inputs) and to-the-point."
if include_sbst_test_file:
test_code = row['sbst_test_file_content']
if test_code != "": # not all instances will have a Pynguin-generated test, so we include this prompt part only to the instances that have one
predicted_test_file_text += "As a hint, we also provide you a test file automatically generated by the Pynguin tool in the <passing_tests> brackets below. These tests cover the modules that changed in the PR and are guaranteed to be syntactically correct and pass in the post-PR code. As such, you can use them, if needed, to get a skeleton for setting up your own test. However, since these tests are automatically generated, it is possible that they are not related to the issue above, in which case feel free to ignore them. "
nTests = 3 # only first 3 pynguin tests to the prompt to keep it from exploding
test_code = keep_first_n_pynguin_tests(test_code, nTests)
predicted_test_file_contents += "<passing_tests>\n%s\n</passing_tests>\n\n" % test_code
if include_issue_description:
issue_text = row['problem_statement']
else:
issue_text = row['problem_statement'].split('\n')[0]
if include_golden_code:
# Add golden code contents
# - whole file
# - random part of file
# - targeted part of file (through AST)
# filenames of the files changed by the golden patch
code_filenames = row['golden_code_names']
if sliced:
code = row['golden_code_contents_sliced_long']
sliced_text = " (only parts relevant to the patch are shown with their respective line numbers)"
else:
code = row['golden_code_contents'] # whole code
code = [add_line_numbers(x) for x in code]
sliced_text = ""
code_string = "This patch will be applied to the file(s) shown within the <code> brackets%s. "%sliced_text
fnames_with_code = ""
for (fname, fcode) in zip(code_filenames, code):
fnames_with_code += "File %s\n%s\n\n" % (fname, fcode)
code_string2 = "<code>\n%s\n</code>\n\n"%fnames_with_code
else:
code_string = ""
code_string2 = ""
if include_issue_comments:
comments_string = "\nIssue comments (discussion):\n %s"%(row['hints_text'])
else:
comments_string = ""
if include_pr_desc:
pr_desc_string = ". The description of this Pull Request is shown in the <pr_description> brackets"
pr_desc_string2 = "<pr_description>\nPR Title: %s\n%s\n</pr_description>\n\n"%(row['title'], row['description'])
else:
pr_desc_string = ""
pr_desc_string2 = ""
_, repo_name, _ = parse_instanceID_string(row['instance_id'])
prompt = f"""The following text contains a user issue (in <issue> brackets) posted at the {repo_name} repository. A developer has submitted a Pull Request (PR) that resolves this issue{pr_desc_string}. Their modification is provided in the form of a unified diff format inside the <patch> brackets. {code_string}{task}{predicted_test_file_text}You must only return a raw test function and you must import any needed modules in that test function. More details at the end of this text.
<issue>
{issue_text}{comments_string}
</issue>
<patch>
{golden_patch}
</patch>
{code_string2}{predicted_test_file_contents}{pr_desc_string2}{task2}{cot_text}Return only one test function at the default indentation level, with all the imports inside the function, WITHOUT considering the integration to the test file, e.g., in a unittest.TestCase class because your raw test function will then be inserted in a file by us, either as a standalone function or as a method of an existing unittest.TestCase class, depending on the file conventions; you must provide only the raw test function and nothing else{task3}"""
return prompt
def build_prompt_tdd(row):
task = "Your task is to write a test function that fails before the issue fix and passes after the fix, hence verifying that the fix resolves the <issue>. This is a test-driven-development environment, so the fix is not ready yet. "
task2 = "Generate a test function that reproduces the <issue>.\n"
task3 = ". The test function should be self-contained and to-the-point. "
issue_text = row['problem_statement']
_, repo_name, _ = parse_instanceID_string(row['instance_id'])
prompt = f"""The following text contains a user issue (in <issue> brackets) posted at the {repo_name} repository. A developer wants to start working on this issue, but first they will need a test function that reproduces the issue. {task}You must only return a raw test function and you must import any needed modules in that test function.
<issue>
{issue_text}
</issue>
{task2}Return only one test function at the default indentation level, with all the imports inside the function, WITHOUT considering the integration to the test file, e.g., in a unittest.TestCase class because your raw test function will then be inserted in a file by us, either as a standalone function or as a method of an existing unittest.TestCase class, depending on the file conventions; you must provide only the raw test function and nothing else{task3}"""
return prompt
def query_model(prompt, model="gpt-4o", T=0.0):
# model: "gpt-4o" | "llama-3.3-70b-versatile" | "deepseek-r1-distill-llama-70b"
if model == "gpt-4o":
model = "gpt-4o-2024-08-06" # pin version of GPT-4o
response = openai.ChatCompletion.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=T
)
return response.choices[0].message.content.strip()
elif model.startswith("llama"):
GROQ_API_KEY = get_api_key("GROQ_API_KEY")
client = Groq(api_key=GROQ_API_KEY)
messages = [{"role": "user", "content": prompt}]
completion = client.chat.completions.create(
model=model,
messages=messages,
max_tokens=700,
temperature=T
)
return completion.choices[0].message.content
elif model.startswith("qwen") or model.startswith("deepseek"):
GROQ_API_KEY = get_api_key("GROQ_API_KEY")
client = Groq(api_key=GROQ_API_KEY)
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": "You are an experienced software tester specializing in developing regression tests. Follow the user's instructions for generating a regression test. The output format is STRICT: do all your reasoning in the beginning, but the end of your output should ONLY contain python code, i.e., NO natural language after the code."},
{"role": "user", "content": prompt}
]
)
return response.choices[0].message.content
####### Naming Convention + Git History Injection #######
from collections import Counter
# Function to run a shell command and return its output
def run_command(command, cwd=None):
result = subprocess.run(command, cwd=cwd, shell=True,
text=True, capture_output=True, check=True)
return result.stdout.strip() if result.returncode == 0 else None
# Function to get the last N commits that edit a file
def get_last_N_commits(file_path, repo_dir, N=10):
command = f"git log -n {N} --pretty=format:'%H' -- {file_path}"
commits = run_command(command, cwd=repo_dir)
return commits.splitlines() if commits else []
# Function to get the files edited in a specific commit
def get_files_in_commit(commit_hash, repo_dir):
command = f"git show --name-only --pretty=format:'' {commit_hash}"
files = run_command(command, cwd=repo_dir)
return files.splitlines() if files else []
# Function to find the most commonly co-edited file for each file in file_list
# starting from base_commit
def find_coedited_files(file_list, repo_dir, n_last_commits=10, n_files=3):
common_files = []
for file in file_list:
# Get the last 10 commits for the current file
commits = get_last_N_commits(file, repo_dir, n_last_commits)
# Get all files edited in the same commits
coedited_files = []
for commit in commits:
coedited_files.extend(get_files_in_commit(commit, repo_dir))
# Remove the file itself from the list of coedited files
coedited_files = [f for f in coedited_files if f != file]
# Filter files starting with "test*"
coedited_files = [f for f in coedited_files if is_test_file(f)]
# Find the most common coedited file and its count
if coedited_files:
most_common_files = Counter(coedited_files).most_common(n_files)
common_files.extend(most_common_files)
return common_files
def is_test_file(filepath, test_folder=''):
is_in_test_folder = False
# If a predefined test folder is given, we check if the filepath contains it
if test_folder:
is_in_test_folder = (test_folder in filepath)
else:
# Otherwise, we want the file to be in a dir where at least one folder in the dir path starts with test
parts = filepath.split('/')
for part in parts[:-1]:
if part.startswith('test'):
is_in_test_folder = True
break
if is_in_test_folder and parts[-1].startswith('test') and parts[-1].endswith(".py"):
return True
else:
return False
def inject_test(row, repo_dir, new_test):
test_file_to_inject, test_content = find_file_to_inject(row, repo_dir)
if test_file_to_inject is None:
print("No suitable file found for %s, skipping" % row['instance_id'])
return None, None, None
new_test_file_content = append_function(test_content, new_test, insert_in_class="NOCLASS")
return test_file_to_inject, test_content, new_test_file_content
def find_file_to_inject(row, repo_dir):
base_commit = row['base_commit']
current_branch = run_command("git rev-parse --abbrev-ref HEAD", cwd=repo_dir)
run_command(f"git checkout {base_commit}", cwd=repo_dir)
try:
edited_files = extract_edited_files(row['patch'])
### First search for the file "test_<x>.py" where "<x>.py" was changed by the golden patch
for edited_file in edited_files:
matching_test_files = [] # could be more than 1 matching files in different dirs
# ".../x.py" => ".../test_x.py"
potential_test_file = 'test_' + edited_file.split('/')[-1]
# Search in all test_ folders for that name
for root, dirs, files in os.walk(repo_dir):
if any(part.startswith("test") for part in root.split(os.sep)):
for file in files:
if file == potential_test_file:
test_file_to_inject = root + '/' + file
matching_test_files.append(test_file_to_inject)
if matching_test_files: # stop in the first file for which we find (possibly >1) matching tests
break
if matching_test_files:
### Then, if the simple naming rule did not work, try Git History
matching_test_files_relative = [y.replace(repo_dir+'/', '') for y in matching_test_files] # make relative
test_file_to_inject = find_most_similar_matching_test_file(edited_file, matching_test_files_relative)
test_file_to_inject = repo_dir + '/%s' % test_file_to_inject # make absolute again
else:
n_last_commits = 10
coedited_files = find_coedited_files(edited_files, repo_dir, n_last_commits)
if not coedited_files:
# if we did not find in the last 10 commits, go to last 100 (only in pylint-dev__pylint-4661)
coedited_files = find_coedited_files(edited_files, repo_dir, 100)
if not coedited_files:
# inject to the first test file we find
first_random_test_file = get_first_test_file(repo_dir)
coedited_files = [(first_random_test_file, 1)] # name is just to fit in
coedited_files = sorted(coedited_files, key=lambda x: -x[1]) # sort by # of co-edits
test_file_to_inject = None
for coedited_file in coedited_files: # coedited_file is a tuple (fname, #coedits)
# we need to check if these files still exist because they
# come from a past commit
if os.path.isfile(repo_dir + '/' + coedited_file[0]):
test_file_to_inject = repo_dir + '/' + coedited_file[0]
break # the first one we find that exists we keep it
if not test_file_to_inject: # if none of the coedited files exist anymore, select the first file you find again
first_random_test_file = get_first_test_file(repo_dir)
test_file_to_inject = repo_dir + '/' + first_random_test_file
# Read the contents of the test file
with open(test_file_to_inject, 'r', encoding='utf-8') as f:
test_content = f.read()
finally:
run_command(f"git checkout {current_branch}", cwd=repo_dir) # Reset to the original commit
return test_file_to_inject, test_content
def get_first_test_file(root_dir: str) -> str | None:
"""
Our last resort: find the first file in a subfolder where at least one component starts with 'test'
and the filename itself also starts with 'test'.
:param root_dir: The root directory to search in.
:return: The first matching file path relative to root_dir, or None if no such file exists.
"""
# First search in folders starting with "test" for files starting with "test"
for dirpath, _, filenames in os.walk(root_dir):
if not any(part.startswith(".") for part in dirpath.split(os.sep)) and any(part.startswith("test") for part in dirpath.split(os.sep)):
for filename in filenames:
if filename.startswith("test"):
return os.path.relpath(os.path.join(dirpath, filename), root_dir)
# If nothing is found, search only for files starting with "test" (e.g., requests repo in old commits)
for dirpath, _, filenames in os.walk(root_dir):
if not any(part.startswith(".") for part in dirpath.split(os.sep)):
for filename in filenames:
if filename.startswith("test"):
return os.path.relpath(os.path.join(dirpath, filename), root_dir)
return None
def find_most_similar_matching_test_file(code_filepath, test_filepaths):
"""
Find the test file whose path is most similar to the code path.
Parameters:
code_filepath (str): Path to the code file (e.g., "astropy/utils/misc.py").
test_filepaths (list): List of test file paths (e.g., ["astropy/utils/tests/test_misc.py", "astropy/visualization/wcsaxes/tests/test_misc.py"]).
Returns:
str: The test file path most similar to the code path.
"""
# Split the code filepath into components
code_components = code_filepath.split(os.sep)
def similarity(test_filepath):
"""Calculate similarity between the code path and a test file path."""
test_components = test_filepath.split(os.sep)
# Count the number of matching components from the start of the paths
match_count = 0
for code_comp, test_comp in zip(code_components, test_components):
if code_comp == test_comp:
match_count += 1
else:
break
return match_count
# Find the test file with the highest similarity
most_similar_test = max(test_filepaths, key=similarity)
return most_similar_test
####### LIBRO Injection #######
def run_libro_injection(repo_dir, commitID, new_test, i, libro_granularity):
"""
1. Save the current HEAD commit
2. Checkout to the input commitID, which is the commitID before the PR
3. Find the best file to inject new_test based on LIBRO injection (file-level or class-level)
4. Append the generated test and return the new contents of the test file
"""
original_dir = os.getcwd() # Save the original working directory
# Change to repo_dir
os.chdir(repo_dir)
# Save the current HEAD state
original_branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=repo_dir, text=True
).strip()
try:
# Checkout the specified commit
subprocess.run(["git", "checkout", commitID], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if libro_granularity == "file":
# File-level
test_filename = get_best_file_to_inject(repo_dir, new_test)
else:
# Class-level
test_filename, test_class = get_best_file_and_class_to_inject(repo_dir, new_test)
test_filename = test_filename[0]
if not test_filename:
print(f"No suitable test file found for new test at i={i}. Skipping.")
#cleanup(original_branch, original_dir) # this is not needed because the
# cleanup from the "finally" block will be executed
return None, None, None
# Read the contents of the test file
with open(test_filename, 'r', encoding='utf-8') as f:
test_content = f.read()
# Append the function to the test file contents
if libro_granularity == "file":
# File-level
new_test_file_contents = append_function(test_content, new_test, insert_in_class="NOCLASS")
else:
# Class-level
new_test_file_contents = append_function(test_content, new_test, insert_in_class=test_class)
return test_filename, test_content, new_test_file_contents
except Exception as e:
print(f"The following exception occurred for i={i}: {e}. Skipping.")
return None, None, None
finally:
cleanup(original_branch, original_dir)
# LIBRO has two modes: 1) file mode: return the best file to inject and
# 2) class mode: return the best file and class name to inject
def extract_tokens_from_code(code):
"""
Extracts all tokens from a Python code string using the tokenize module.
Returns a set of token strings (ignores comments and whitespace).
"""
tokens = set()
try:
for token in tokenize.tokenize(BytesIO(code.encode('utf-8')).readline):
if token.type in {tokenize.NAME, tokenize.NUMBER, tokenize.STRING}:
tokens.add(token.string)
except tokenize.TokenError:
pass # Handle incomplete input gracefully
return tokens
def get_best_file_to_inject(repo_dir, gen_test):
"""
Finds the Python test file in "repo_dir" that is most similar to "gen_test" based on tokens.
Searches through all subfolders starting with "test*/" for .py files.
"""
# Prepare tokens for the generated test
gen_test_tokens = extract_tokens_from_code(gen_test)
file_scores = defaultdict(float)
# Walk through the repo directory, focusing on test*/ subfolders and .py files
for root, dirs, files in os.walk(repo_dir):
if any(part.startswith("test") for part in root.split(os.sep)) and not any(part.startswith(".") for part in root.split(os.sep)):
for file in files:
if file.startswith("test") and file.endswith(".py"):
file_path = os.path.join(root, file)
# Read the content of the Python file
try:
with open(file_path, 'r', encoding='utf-8') as f:
file_content = f.read()
except (OSError, UnicodeDecodeError):
continue # Skip files that cannot be read or decoded
# Extract tokens and compute similarity
file_tokens = extract_tokens_from_code(file_content)
if file_tokens:
intersection = len(gen_test_tokens & file_tokens)
similarity_score = intersection/len(gen_test_tokens)
file_scores[file_path] = similarity_score
# Identify the most similar file
if file_scores:
best_file = max(file_scores, key=file_scores.get)
return best_file, file_scores[best_file]
else:
return None, 0.0 # No suitable file found
# Helper for get_best_file_and_class_for_injection
def extract_classes_from_code(file_tokens, code):
class_tokens = {}
try:
tree = ast.parse(code)
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
class_start = node.lineno - 1
# If Python 3.8+, node should have an end_lineno
if hasattr(node, 'end_lineno'):
class_end = node.end_lineno
else:
# Fallback if end_lineno doesn't exist:
class_end = max((child.lineno for child in ast.walk(node) if hasattr(child, 'lineno')), default=node.lineno)
# class_end here is 1-based index. We don't need to add +1 because slicing excludes the end index
# and we're using node.end_lineno directly which is inclusive.
# So we do not subtract one from class_end because end_lineno is the *last* line number (1-based).
# When slicing, we use [class_start:class_end], because class_end is 1-based and slicing excludes
# the end line. For a 1-based end line number, slicing up to that number will include it because the
# slice index is zero-based. Let's break it down:
# - If end_lineno = 10, that means line 10 (1-based).
# - Our split lines are zero-based: line 10 is at index 9.
# - slice: splitlines()[class_start:class_end]
# If class_end=10 (1-based), slicing up to index 10 will include index 9.
class_code = "\n".join(code.splitlines()[class_start:class_end])
class_tokens[node.name] = extract_tokens_from_code(class_code)
except (SyntaxError, ValueError):
pass
return class_tokens
def get_best_file_and_class_to_inject(repo_dir, gen_test):
"""
Finds the Python test class in "repo_dir" that is most similar to "gen_test" based on tokens.
Searches through all subfolders starting with "test*/" for .py files.
"""
# Prepare tokens for the generated test
gen_test_tokens = extract_tokens_from_code(gen_test)
class_scores = [] # Stores (filename, class_name, similarity_score)
# Walk through the repo directory, focusing on test*/ subfolders and .py files
for root, dirs, files in os.walk(repo_dir):
if any(part.startswith("test") for part in root.split(os.sep)):
for file in files:
if file.startswith("test") and file.endswith(".py"):
file_path = os.path.join(root, file)
# Read the content of the Python file
try:
with open(file_path, 'r', encoding='utf-8') as f:
file_content = f.read()
except (OSError, UnicodeDecodeError):
continue # Skip files that cannot be read or decoded
# Tokenize the entire file
file_tokens = extract_tokens_from_code(file_content)
# Extract classes and their tokens
classes = extract_classes_from_code(file_tokens, file_content)
if classes:
for class_name, class_token_set in classes.items():
if class_token_set:
intersection = len(gen_test_tokens & class_token_set)
union = len(gen_test_tokens)
similarity_score = intersection / union if union > 0 else 0
class_scores.append((file_path, class_name, similarity_score))
else:
# Treat the whole file as a pseudo-class if no classes exist
if file_tokens:
intersection = len(gen_test_tokens & file_tokens)
union = len(gen_test_tokens)
similarity_score = intersection / union if union > 0 else 0
class_scores.append((file_path, "NOCLASS", similarity_score))
# Identify the most similar class
if class_scores:
best_file, best_class, best_score = max(class_scores, key=lambda x: x[2])
return best_file, best_class
else:
return None, None # No suitable class found
# If anything goes wrong, reset to the initial commit
def cleanup(original_branch, original_dir):
subprocess.run(["git", "fetch"], check=True, stdout=subprocess.DEVNULL)
subprocess.run(["git", "checkout", original_branch], check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# Change back to the original directory
os.chdir(original_dir)
################################################################
################## Golden Injection ############################
def extract_function_signatures(code_string):
"""
Extract the signatures of all functions from a string of Python code.
Assumes functions have only positional arguments and no default arguments.
Parameters:
code_string (str): A string of Python code defining one or more functions.
Returns:
list: A list of function signatures as strings.
"""
# Parse the code into an Abstract Syntax Tree (AST)
tree = ast.parse(code_string)
# Collect all function signatures
function_signatures = []
for node in tree.body:
if isinstance(node, ast.FunctionDef):
# Extract function name
func_name = node.name
# Extract positional arguments
args = [arg.arg for arg in node.args.args]
# Construct the function signature
args_str = ", ".join(args)
signature = f"{func_name}({args_str})"
# Append the signature to the list
function_signatures.append(signature)
return function_signatures
def add_self_argument(function_signature):
"""
Adds 'self' as the first argument in a Python function signature.
Args:
function_signature (str): The function signature as a string.
Returns:
str: The updated function signature with 'self' as the first argument.
"""
# Regex to match the function name and argument list
pattern = re.compile(r'^(\w+\s*\()(.*)(\))$')
match = pattern.match(function_signature)
if match:
function_name = match.group(1) # The function name and opening parenthesis
arguments = match.group(2).strip() # The argument list
closing_parenthesis = match.group(3) # The closing parenthesis
if arguments:
# if there are already arguments
if "self" not in arguments:
# and they do not already contain "self", add it.
# This is necessary to avoid adding "self" two times
updated_arguments = f"self, {arguments}"
else:
# if "self" is already there, don't change anything. Sometimes the LLM
# will output the function with "self" as a param
updated_arguments = arguments
else:
updated_arguments = "self"
return f"{function_name}{updated_arguments}{closing_parenthesis}"
return function_signature
def get_best_file_to_inject_golden(initial_content_arr, changed_content_arr, fname_arr, new_test):
"""The golden test patch may contain >1 edited test file. In that case,
we seek the most similar (token-wise) to the generated test (new_test) and
inject the new test there.
"""
# Only consider files start with test*.py
files_starting_with_test = [x for x in fname_arr if x.startswith('test')]
r = {}
for (initial_content, changed_content, fname) in zip(initial_content_arr, changed_content_arr, fname_arr):
if files_starting_with_test and not fname in files_starting_with_test:
# If there is at least one file starting with test*, we skip files not starting with test*
continue
changed_funcs_or_classes, success = find_changed_funcs_or_classes(initial_content, changed_content)
# changed_funcs_or_classes: [("function"/"class", name, code)]
if success:
r[fname] = changed_funcs_or_classes
# return the class/func with the highest token similarity with the
# generated test
new_test_tokens = extract_tokens_from_code(new_test)
max_similarity = 0
max_similarity_file = None
max_similarity_class_or_func = None
for file, class_or_func_arr in r.items():
for class_or_func in class_or_func_arr:
contents = class_or_func[2]
tkns = extract_tokens_from_code(contents)
if tkns:
intersection = len(new_test_tokens & tkns)
similarity_score = intersection/len(new_test_tokens)
if similarity_score > max_similarity:
max_similarity = similarity_score
max_similarity_class_or_func = class_or_func
max_similarity_file = file
success = max_similarity_class_or_func is not None
return max_similarity_class_or_func, max_similarity_file, success
def find_changed_funcs_or_classes(initial_content, changed_content):
"""
We want to find changed funcs or classes between two versions of a test file
to locate in which place we should insert the model-generated test:
we apply the golden test patch to the golden test file and check for changed
funcs/classes and insert our model-generated test patch there
"""
success = True # for handling errors in the higher level
def extract_top_level_defs(content):
"""Extract top-level function and class definitions from the Python content."""
tree = ast.parse(content)
definitions = {}
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.ClassDef)):
start_lineno = node.lineno
end_lineno = node.end_lineno if hasattr(node, 'end_lineno') else None
name = node.name
type_ = 'class' if isinstance(node, ast.ClassDef) else 'function'
definitions[name] = (type_, start_lineno, end_lineno)
return definitions
def extract_code_block(content, start, end):
"""Extract the specific code block based on line numbers."""
lines = content.splitlines()
return '\n'.join(lines[start - 1:end])
try:
initial_defs = extract_top_level_defs(initial_content)
changed_defs = extract_top_level_defs(changed_content)
except SyntaxError as e:
# If the file content is not compilable we cannot use the AST to extract top level defs.
# In this case, skip the instance
success = False
return [], success
modified_or_missing = []
# For model only uncomment these
for name, (type_, start, end) in initial_defs.items():
if name not in changed_defs:
# If the definition is missing in changed_content
code = extract_code_block(initial_content, start, end)
modified_or_missing.append((type_, name, code))
else:
# Check if the content has changed
initial_block = extract_code_block(initial_content, start, end)
changed_type, changed_start, changed_end = changed_defs[name]
changed_block = extract_code_block(changed_content, changed_start, changed_end)
if list(difflib.unified_diff(initial_block.splitlines(), changed_block.splitlines())):
modified_or_missing.append((type_, name, initial_block))
# # For golden_and_model uncomment these
# for name, (type_, start, end) in changed_defs.items():
# if name not in initial_defs:
# # If the definition is missing in changed_content
# code = extract_code_block(changed_content, start, end)
# modified_or_missing.append((type_, name, code))
# else:
# # Check if the content has changed
# changed_block = extract_code_block(changed_content, start, end)
# initial_type, initial_start, initial_end = initial_defs[name]
# initial_block = extract_code_block(initial_content, initial_start, initial_end)
# if list(difflib.unified_diff(initial_block.splitlines(), changed_block.splitlines())):
# modified_or_missing.append((type_, name, changed_block))
return modified_or_missing, success
def apply_patch(file_content_arr, patch):
"""
Apply a patch to file_content using the equivalent of "git apply".
Parameters:
file_content (str): The original content of the file.
patch (str): The patch content in unified diff format.
Returns:
str: The updated file content after applying the patch.
IMPORTANT: For `git apply` (and hence, this function) to run, it must be called from
within a git repository. Alternatively we would have to use `apply` which is less nice.
"""
temp_dir = './git_sandbox/'
if not os.path.exists(temp_dir):
os.mkdir(temp_dir)
# Important: in order for "git apply" to work, it needs to be inside of a .git repo
# so we initialize one and delete the .git at the end
res = subprocess.run(["git", "init", "."], check=True, cwd=temp_dir, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# Extract all the names of the changed files and save them in a list.
# Each element of the list is of the form "a/<file_path>"
patch_lines = patch.splitlines()
original_file_aprefix_arr = []
for line in patch_lines:
if line.startswith("--- "):
original_file_aprefix = line.split(" ")[1]
original_file_aprefix_arr.append(original_file_aprefix)
# Files mentioned in the patch should be the same number as the ones
# whose content is provided. We also assume that the i-th file in the
# first content corresponds to the i-th file in the second.
assert len(original_file_aprefix_arr) == len(file_content_arr), patch
file_path_arr = []
for (original_file_aprefix, file_content) in zip(original_file_aprefix_arr, file_content_arr):
# remove intermediate folders: "a/django/tests/x.py" => "a/x.py"
file_path = original_file_aprefix.replace('/', '_')
file_path_arr.append(file_path)
file_path_aprefix = 'a/' + file_path
file_path_bprefix = 'b/' + file_path
patch = patch.replace(original_file_aprefix, file_path_aprefix) # update patch accordingly
original_file_bprefix = 'b/' + '/'.join(original_file_aprefix.split('/')[1:])
patch = patch.replace(original_file_bprefix, file_path_bprefix)
# Write the file content and patch content to their respective files
with open(temp_dir + file_path, "w") as file:
file.write(file_content)
patch_path = "patch.diff"
with open(temp_dir + patch_path, "w") as patch_file:
patch_file.write(patch)
# Apply the patch using git apply
try:
res = subprocess.run(["git", "apply", "--reject", patch_path],
check=True,
capture_output=True,
text=True,
cwd=temp_dir)
except subprocess.CalledProcessError as e:
# Restore the working directory
os.remove(temp_dir + patch_path)
for file_path in file_path_arr:
os.remove(temp_dir + file_path)
#pass
subprocess.run(["rm", "-rf", ".git/"], cwd=temp_dir, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
raise ValueError(f"Failed to apply patch: {e}")
updated_content_all_files = []
for file_path in file_path_arr:
# Read the updated file content
with open(temp_dir + file_path, "r") as file:
updated_content = file.read()
os.remove(temp_dir + file_path)
updated_content_all_files.append(updated_content)
os.remove(temp_dir + patch_path)
subprocess.run(["rm", "-rf", ".git/"], cwd=temp_dir, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return updated_content_all_files, res.stderr
def adjust_function_indentation(function_code: str) -> str:
"""
Adjusts the indentation of a Python function so that the function definition
has no leading spaces, and the internal code indentation is adjusted accordingly.
:param function_code: A string representing the Python function.
:return: The adjusted function code as a string.
# Example Usage
function_code = \"\"\"
def example_function(param):
if param:
print("Hello, world!")
else:
print("Goodbye, world!")
\"\"\"
adjusted_code = adjust_function_indentation(function_code)
print(adjusted_code)
"""
lines = function_code.splitlines()
if not lines:
return ""
# Find the leading spaces of the first non-empty line
first_non_empty_line = next(line for line in lines if line.strip())
leading_spaces = len(first_non_empty_line) - len(first_non_empty_line.lstrip())
# Adjust the indentation by removing the leading spaces
adjusted_lines = []
for line in lines:
if line.strip(): # Non-empty line
adjusted_lines.append(line[leading_spaces:])
else: # Empty line
adjusted_lines.append("")
return "\n".join(adjusted_lines)
def append_function(file_content: str, new_function: str, insert_in_class: str = "NOCLASS") -> str:
"""
Append the function new_function to file_content. If insert_in_class is a class name,
insert new_function as a method of that class. Otherwise, insert new_function at the bottom
of the file_content.
This handles the indentation.
"""
# Parse the content using the AST module
tree = ast.parse(file_content)
if insert_in_class != "NOCLASS":
# Add the self argument - if not already exists
new_function_signature = extract_function_signatures(new_function)
for signature in new_function_signature: # we may have >1 added functions
signature_with_self = add_self_argument(signature)
new_function = new_function.replace(signature, signature_with_self)
# Search for the specified class
target_class = None
for node in tree.body:
if isinstance(node, ast.ClassDef) and node.name == insert_in_class:
target_class = node
break
if target_class is None:
raise ValueError(f"Class '{insert_in_class}' not found in the file content!")
# Find the indentation level of the class
lines = file_content.splitlines()
class_start_line = target_class.lineno - 1
class_indentation = len(lines[class_start_line]) - len(lines[class_start_line].lstrip())
# Locate the last method in the class
last_method = None
for body_node in target_class.body:
if isinstance(body_node, ast.FunctionDef):
last_method = body_node
if last_method:
# Insert after the last method
last_line = last_method.end_lineno # Use end_lineno for accurate insertion
else:
# If no methods, insert at the end of the class
last_line = target_class.end_lineno