ATLAS Offline Software
Loading...
Searching...
No Matches
update_ci_reference_files.py
Go to the documentation of this file.
1#!/bin/env python3
2# Copyright (C) 2002-2026 CERN for the benefit of the ATLAS collaboration
3
4
5"""Updates reference files for a given MR, as well as related files (digest ref files, References.py)
6
7This script should be run in the root directory of the athena repository,
8and you should pass in the URL of "CI Builds Summary" page for the MR you are interested in.
9i.e. the link that you get from the MR under "Full details available on <this CI monitor view>"
10
11So, for example, if you are interested in MR 66303, you would run this script as follows:
12Tools/PROCTools/scripts/update_ci_reference_files.py https://bigpanda.cern.ch/ciview/?rel=MR-63410-2023-10-09-12-27
13
14Running with --test-run will modify local files (so you can test that the changes make sense), and will also print out the commands which would have been executed. Nothing remote is changed!
15This is a good way to check that the proposed changes look rational before actually making in earnest.
16"""
17
18from collections import defaultdict
19import html
20import os
21import sys
22import subprocess
23import re
24import argparse
25try:
26 import gitlab
27 import requests
28except ImportError:
29 print('FATAL: this script needs the gitlab and requests modules. Either install them yourself, or run "lsetup gitlab"')
30
31class CITest:
32 def __init__(self, name, tag, mr, date, existing_ref, existing_version, new_version, new_version_directory, copied_file_path, diff, type):
33 self.name = name
34 self.tag = tag
35 self.mr = mr
36 self.date = date
37 self.existing_ref = existing_ref
38 self.existing_version = existing_version
39 self.new_version = new_version
40 self.new_version_directory = new_version_directory
41 self.copied_file_path = copied_file_path
42 self.diff = diff
43 self.shared_ref = False # uses ref from another test
44 self.type = type
45
46 def __repr__(self):
47 return f'<CI Test: {self.name} tag: {self.tag} MR: {self.mr} date: {self.date} type: {self.type}>'
48
49 def __str__(self):
50 extra = ''
51 if self.type == 'DiffPool':
52 extra = f' Data file change : {self.existing_version} -> {self.new_version}'
53 if self.shared_ref: extra += ' (shared ref)'
54 elif self.type == 'Digest':
55 extra = f' Digest change: {self.existing_ref}'
56 elif self.type == 'Content':
57 extra = f' AOD content change: {self.existing_ref}'
58 return f'{self.name}:{self.tag} MR: {self.mr}'+extra
59
60failing_tests = defaultdict(list) # Key is branch, value is list of CITest objects
61dirs_created=[] #Used later to ensure we don't try to create the same directory twice
62debug = False
63
64def process_log_file(url, branch, test_name):
65 """So now we have a URL to a failing test.
66 We need to check that the test is failing for the correct reason - namely a reference file which needs updating
67 The information we need to collect is:
68 - the AMI tag of the failing tests
69 - the merge request number
70 - the location of the reference file
71 - the location of the copied file
72 - the name of the test
73 - the new version number
74 - the new version directory
75 """
76 page = requests.get(url)
77 text = page.text
78
79 # First check that this looks like a test whose ref files need updating, bail otherwise
80 # INFO All q442 athena steps completed successfully
81 test_match = re.search(r'All (?P<ami_tag>\w+) athena steps completed successfully', text)
82 ami_tag = test_match.group('ami_tag') if test_match else None
83
84 # We have two types of tests, but lets try to extract some common information
85 if not ami_tag:
86 # Okay, maybe it was truncated? Try again.
87 match_attempt_2 = re.search(r'AMIConfig (?P<ami_tag>\w+)', text)
88 if match_attempt_2:
89 ami_tag = match_attempt_2.group('ami_tag')
90
91 if not ami_tag:
92 print('WARNING: Did not find an AMI tag in the test "{}". Ignoring.'.format(test_name))
93 return
94
95 mr_match = re.search(r'ARDOC_TestLog_MR-(?P<mr_number>\d+)-(?P<date>\d{4}-\d{2}-\d{2}-\d{2}-\d{2})', url)
96 if not mr_match:
97 print('FATAL: Could not process the URL as expected. Aborting.')
98 print(url)
99 sys.exit(1)
100
101 mr_number = mr_match.group('mr_number')
102 date = mr_match.group('date')
103 human_readable_date = ':'.join(date.split('-')[0:3]) + " at " + ':'.join(date.split('-')[3:])
104
105 if "Your change breaks the digest in test" in text or 'ERROR Your change modifies the output in test' in text:
106 # Okay, we have a digest change
107 failing_tests[branch].append(process_digest_change(text, ami_tag, mr_number, human_readable_date, test_name))
108
109 if 'ERROR Your change breaks the frozen tier0 policy in test' in text or 'ERROR Your change breaks the frozen derivation policy in test' in text:
110 # DiffPool change
111 failing_tests[branch].append(process_diffpool_change(text, ami_tag, mr_number, human_readable_date, test_name))
112
113 return
114
115def process_diffpool_change(text, ami_tag, mr_number, human_readable_date, test_name):
116 eos_path_root = '/eos/atlas/atlascerngroupdisk/data-art/grid-input/WorkflowReferences/'
117
118 # Copied file path
119 # e.g. from ERROR Copied '../SimulationRun3FullSim/run_s4006/myHITS.pool.root' to '/eos/atlas/atlascerngroupdisk/proj-ascig/gitlabci/MR63410_a84345c776e93f0d7f25d00c9e91e35bcb965d09/SimulationRun3FullSimChecks'
120 copied_file_match = re.search(r'^ERROR Copied.*', text, flags=re.MULTILINE)
121 if not copied_file_match:
122 print("FATAL: Could not find matching copied file")
123 sys.exit(1)
124 copied_file_path = copied_file_match.group().split('to')[1].strip().strip("'").strip("&#x27;")+'/'
125
126 # Reference file paths
127 ref_file_match = re.search(r'INFO Reading the reference file from location.*', text)
128 if not ref_file_match:
129 print("FATAL: Could not find matching reference file")
130 sys.exit(1)
131
132 ref_file_path = ref_file_match.group().split('location')[1].strip()
133 existing_version_number= ref_file_path.split('/')[-2]
134 branch = ref_file_path.split('/')[-4]
135 new_version_number = 'v'+str(int(existing_version_number[1:])+1)
136 new_version_directory = eos_path_root+branch+'/'+ami_tag+'/'+new_version_number
137 old_version_directory = eos_path_root+branch+'/'+ami_tag+'/'+existing_version_number
138 # Copied file path
139 # e.g. from ERROR Copied '../SimulationRun3FullSim/run_s4006/myHITS.pool.root' to '/eos/atlas/atlascerngroupdisk/proj-ascig/gitlabci/MR63410_a84345c776e93f0d7f25d00c9e91e35bcb965d09/SimulationRun3FullSimChecks'
140 copied_file_match = re.search(r'^ERROR Copied.*', text, flags=re.MULTILINE)
141 if not copied_file_match:
142 print("FATAL: Could not find matching copied file")
143 sys.exit(1)
144
145 # Sanity checks
146 ami_tag_check = ref_file_path.split('/')[-3].strip()
147 if ami_tag_check!=ami_tag:
148 print('FATAL: Sanity check: "{}" from reference file path "{}" does not match ami tag "{}" extracted previously.'.format(ami_tag_check, ref_file_path, ami_tag))
149 sys.exit(1)
150
151
152 test = CITest(name=test_name, tag=ami_tag, mr=mr_number, date=human_readable_date, existing_ref = old_version_directory, existing_version = existing_version_number, new_version = new_version_number, new_version_directory = new_version_directory, copied_file_path = copied_file_path, diff=None, type='DiffPool')
153 return test
154
155def process_digest_change(text, ami_tag, mr_number, human_readable_date, test_name):
156 # Some things aren't so relevant for digest changes
157 existing_version_number = None
158 new_version_directory = None
159 copied_file_path = None
160 new_version_number=None
161
162 # differs from the reference 'q447_AOD_digest.ref' (<):
163 ref_file_match = re.search(
164 r"differs from the reference (?:'|&#x27;)([^'&]+?)(?:'|&#x27;)",
165 text
166 )
167 if not ref_file_match:
168 print("FATAL: Could not find matching reference file")
169 sys.exit(1)
170 ref_file_path = ref_file_match.group(1)
171
172 diff_lines = []
173 diff_started = False # Once we hit the beginning of the diff, we start recording
174 # Diff starts with e.g.
175 # ERROR The output 'q449_AOD_digest.txt' (>) differs from the reference 'q449_AOD_digest.ref' (<):
176 # and ends with next INFO line
177
178 for line in text.split('\n'):
179 if 'differs from the reference' in line:
180 # Start of the diff
181 diff_started = True
182 elif diff_started:
183 if 'INFO' in line:
184 # End of the diff
185 break
186 elif len(line)>0:
187 diff_lines.append(html.unescape(line))
188
189 test = CITest(name=test_name, tag=ami_tag, mr=mr_number, date=human_readable_date, existing_ref = ref_file_path, existing_version = existing_version_number, new_version = new_version_number, new_version_directory = new_version_directory, copied_file_path = copied_file_path, diff=diff_lines, type='Content' if 'content.ref' in ref_file_path else 'Digest')
190 return test
191
192def update_reference_files(actually_update=True, update_local_files=False):
193 print()
194 print('Updating reference files')
195 print('========================')
196 commands = []
197 for branch, tests in failing_tests.items():
198 for test in tests:
199 print('Processing test: {} on branch {}'.format(test.name, branch))
200 if test.type == 'DiffPool':
201 if test.shared_ref:
202 print(' * This is a DiffPool test but uses a shared reference. No update needed.')
203 continue
204
205 print(' * This is a DiffPool test, and currently has version {} of {}. Will update References.py with new version.'.format(test.existing_version, test.tag))
206 if actually_update:
207 print(' -> The new version is: {}. Creating directory and copying files on EOS now.'.format(test.new_version))
208 create_dir_and_copy_refs(test, True)
209 else:
210 # We will print these later, so we can sanity check them when in test mode
211 commands.extend(create_dir_and_copy_refs(test, False))
212 # Remove any duplicates, whilst preserving the order
213 commands = list(dict.fromkeys(commands))
214
215 # Now, update local References.py file
216 if update_local_files:
217 data = []
218 if debug:
219 print ('Updating local References.py file with new version {} for tag {}'.format(test.new_version, test.tag))
220 line_found = False
221 with open('Tools/WorkflowTestRunner/python/References.py', 'r') as f:
222 lines = f.readlines()
223 for line in lines:
224 if test.tag in line:
225 if test.existing_version in line:
226 line = line.replace(test.existing_version, test.new_version)
227 else:
228 print('')
229 print('** WARNING: For tag {} we were looking for existing version {}, but the line in the file is: {}'.format(test.tag, test.existing_version, line), end='')
230 print('** Are you sure your branch is up-to-date with main? We cannot update an older version of References.py!')
231 line_found = True
232 data.append(line)
233
234 if not line_found:
235 print('** WARNING - no matching line was found for the AMI tag {} in References.py. Are you sure your branch is up-to-date with main? We cannot update an older version of References.py!'.format(test.tag))
236
237 with open('Tools/WorkflowTestRunner/python/References.py', 'w') as f:
238 f.writelines(data)
239 elif test.type == 'Digest' and update_local_files:
240 print(' * This is a Digest test. Need to update reference file {}.'.format(test.existing_ref))
241 data = []
242
243 diff_line=0 # We will use this to keep track of which line in the diff we are on
244 digest_old = [line for line in test.diff if line.startswith('<')]
245 digest_new = [line for line in test.diff if line.startswith('>')]
246
247 with open('Tools/PROCTools/data/'+test.existing_ref, 'r') as f:
248 lines = f.readlines()
249 for current_line, line in enumerate(lines):
250 split_curr_line = line.split()
251 if (split_curr_line[0] == 'run'): # Skip header line
252 data.append(line)
253 continue
254
255 # So, we expect first two numbers to be run/event respectively
256 if (not split_curr_line[0].isnumeric()) or (not split_curr_line[1].isnumeric()):
257 print('FATAL: Found a line in current digest which does not start with run/event numbers: {}'.format(line))
258 sys.exit(1)
259
260 split_old_diff_line = digest_old[diff_line].split()
261 split_old_diff_line.pop(0) # Remove the < character
262 split_new_diff_line = digest_new[diff_line].split()
263 split_new_diff_line.pop(0) # Remove the > character
264
265 # Let's check to see if the run/event numbers match
266 if split_curr_line[0] == split_old_diff_line[0] and split_curr_line[1] == split_old_diff_line[1]:
267 # Okay so run/event numbers match. Let's just double-check it wasn't already updated
268 if split_curr_line!=split_old_diff_line:
269 print('FATAL: It seems like this line was already changed.')
270 print('Line we expected: {}'.format(test.old_diff_lines[diff_line]))
271 print('Line we got : {}'.format(line))
272 sys.exit(1)
273
274 # Check if the new run/event numbers match
275 if split_curr_line[0] == split_new_diff_line[0] and split_curr_line[1] == split_new_diff_line[1]:
276 #Replace the existing line with the new one, making sure we right align within 12 characters
277 data.append("".join(["{:>12}".format(x) for x in split_new_diff_line])+ '\n')
278 if ((diff_line+1)<len(digest_old)):
279 diff_line+=1
280 continue
281
282 # Otherwise, we just keep the existing line
283 data.append(line)
284
285 print(' -> Updating PROCTools digest file {}'.format(test.existing_ref))
286 with open('Tools/PROCTools/data/'+test.existing_ref, 'w') as f:
287 f.writelines(data)
288 elif test.type == 'Content' and update_local_files:
289 print(' * This is a Content test. Need to update reference file {}.'.format(test.existing_ref))
290 subprocess.run(f'patch --quiet Tools/PROCTools/data/{test.existing_ref}',
291 input='\n'.join(test.diff)+'\n',
292 text=True, shell=True, check=True)
293
294 return commands
295
296
297def create_dir_and_copy_refs(test, actually_update=False):
298 """
299 If called with actually_update=False, this function will return a list of commands which would have been executed.
300 """
301 commands = []
302
303 # Nothing to do if test uses a shared reference
304 if test.shared_ref is True:
305 return commands
306
307 if test.new_version_directory not in dirs_created:
308 commands.append("mkdir -p " + test.new_version_directory)
309 dirs_created.append(test.new_version_directory)
310
311 # Copy new directory first, then copy old (in case the new MR did not touch all files)
312 # Important! Use no-clobber for second copy or we will overwrite the new data with old!
313 commands.append("cp " + test.copied_file_path + "* "+ test.new_version_directory+"/")
314 commands.append("cp -n " + test.existing_ref + "/* "+ test.new_version_directory+"/")
315 if actually_update:
316 print(' -> Copying files from {} to {}'.format(test.copied_file_path, test.new_version_directory))
317 try:
318 for command in commands:
319 try:
320 subprocess.call( command, shell=True)
321 except Exception as e:
322 print('Command failed due to:', e)
323 print('Do you have EOS available on this machine?')
324 except Exception as e:
325 print('FATAL: Unable to copy files due to:', e)
326 sys.exit(1)
327
328 f = open(test.new_version_directory+'/info.txt', 'w')
329 f.write('Merge URL: https://gitlab.cern.ch/atlas/athena/-/merge_requests/{}\n'.format(test.mr))
330 f.write('Date: {}\n'.format(test.date))
331 f.write('AMI: {}\n'.format(test.tag))
332 f.write('Test name: {}\n'.format(test.name))
333 f.write('Files copied from: {}\n'.format(test.copied_file_path))
334 f.close()
335
336 return commands
337
339 # Each list entry is one column in the table.
340 for row in data:
341 if ('ERROR' in row[0]):
342 process_log_file(strip_url(row[2]), branch = row[1], test_name=strip_href(row[2]))
343
344def strip_url(href):
345 url = href[href.find('"')+1:] # Strip everything up to first quotation mark
346 url = url[:url.find('"')]
347 return url
348
349def strip_href(href):
350 value = href[href.find('>')+1:] # Strip everything up to first >
351 value = value[:value.find('<')]
352 return value
353
355 # Each entry is one column in the table. 11th is the tests column.
356 # URL to tests page is in form:
357 # <a href="/testsview/?nightly=MR-CI-builds&rel=MR-66303-2023-10-10-19-08&ar=x86_64-centos7-gcc112-opt&proj=AthGeneration">0 (0)</a>
358 test_counts = strip_href(project[11])
359 # This is e.g. '0 (0)'
360 test_error_counts = int(test_counts.split(' ')[0])
361 if test_error_counts > 0:
362 # Okay, we have an error!
363 project_url = 'https://bigpanda.cern.ch'+strip_url(project[11])
364 headers = {'Accept': 'application/json'}
365 r = requests.get(project_url+'&json', headers=headers)
366 data = r.json()["rows_s"]
367 process_CI_Tests_json(data[1:])
368
370 headers = {'Accept': 'application/json'}
371 r = requests.get(url+'&json', headers=headers)
372 data = r.json()["rows_s"]
373 # First row is header.
374 # Currently this is: 'Release', 'Platform', 'Project', 'git branch<BR>(link to MR)', 'Job time stamp', 'git clone', 'Externals build', 'CMake config', 'Build time', 'Comp. Errors (w/warnings)', 'Test time', 'CI tests errors (w/warnings)', 'Host'
375 for project in data[1:]:
377
378
380 # Tests that are allowed to use the same reference. The key is the test that uses the
381 # reference of its value.
382 shared_refs = {
383 'CITest_DerivationRun2Data_PHYS_MT-test': 'CITest_DerivationRun2Data_PHYS-test',
384 'CITest_DerivationRun2MC_PHYS_MT-test': 'CITest_DerivationRun2MC_PHYS-test',
385 'CITest_DerivationRun3Data_PHYS_MT-test': 'CITest_DerivationRun3Data_PHYS-test',
386 'CITest_DerivationRun3MC_PHYS_MT-test': 'CITest_DerivationRun3MC_PHYS-test',
387 'CITest_DerivationRun2Data_PHYSLITE_MT-test': 'CITest_DerivationRun2Data_PHYSLITE-test',
388 'CITest_DerivationRun2MC_PHYSLITE_MT-test': 'CITest_DerivationRun2MC_PHYSLITE-test',
389 'CITest_DerivationRun3Data_PHYSLITE_MT-test': 'CITest_DerivationRun3Data_PHYSLITE-test',
390 'CITest_DerivationRun3MC_PHYSLITE_MT-test': 'CITest_DerivationRun3MC_PHYSLITE-test',
391 }
392
393 for branch,tests in failing_tests.items():
394 # Create dictionary of ref vs tests
395 refs = defaultdict(list) # tag : [test,...]
396 for test in tests:
397 refs[test.tag].append(test)
398
399 for r, dups in refs.items():
400 if len(dups) <= 1:
401 continue
402 for test in dups:
403 # Mark test as having shared ref if itself and its reference is in the list
404 if (name := shared_refs.get(test.name)) and any(name==t.name for t in dups):
405 test.shared_ref = True
406
407
408def summarise_failing_tests(check_for_duplicates = True):
409 print('Summary of tests which need work:')
410
411 if not failing_tests:
412 print(" -> None found. Aborting.")
413 return None
414
415 mr = None
416 reference_folders = []
417 for branch,tests in failing_tests.items():
418 print (' * Branch: {}'.format(branch))
419 for test in tests:
420 print(' - ', test)
421 if test.type == 'DiffPool':
422 if not test.new_version_directory:
423 print('FATAL: No path to "new version" for test {} of type DiffPool.'.format(test.name))
424 sys.exit(1)
425
426 if os.path.exists(test.new_version_directory):
427 msg = f'WARNING: The directory {test.new_version_directory} already exists. Are you sure you want to overwrite the existing references?'
428 if input("%s (y/N) " % msg).lower() != 'y':
429 sys.exit(1)
430
431 if (test.existing_ref not in reference_folders):
432 reference_folders.append(test.existing_ref)
433 elif check_for_duplicates and not test.shared_ref:
434 print('FATAL: Found two tests which both change the same reference file: {}, which is not supported.'.format(test.existing_ref))
435 print('Consider running again in --test-run mode, to get a copy of the copy commands that could be run.')
436 print('The general advice is to take the largest file (since it will have the most events).')
437 sys.exit(1)
438 mr = test.mr
439 return 'https://gitlab.cern.ch/atlas/athena/-/merge_requests/'+mr
440
441if __name__ == '__main__':
442 parser = argparse.ArgumentParser(description=__doc__,
443 formatter_class=argparse.RawDescriptionHelpFormatter)
444 parser.add_argument('url', help='URL to CITest (put in quotes))')
445 parser.add_argument('--test-run',help='Update local text files, but do not actually touch EOS.', action='store_true')
446 args = parser.parse_args()
447 print('Update reference files for URL: {}'.format(args.url))
448
449 if not args.url.startswith(('http://', 'https://')):
450 print('invalid url - should start with http:// or https://')
451 print(args.url)
452 print('Aborting.')
453 sys.exit(1)
454
455 if args.test_run:
456 print(' -> Running in test mode so will not touch EOS, but will only modify files locally (these changes can easily be reverted with "git checkout" etc).')
457
458 print('========================')
461 mr_url = summarise_failing_tests(not args.test_run)
462 if not mr_url:
463 sys.exit(1)
464 print('========================')
465
466 # Retrieve MR infos:
467 gl_project = gitlab.Gitlab("https://gitlab.cern.ch").projects.get("atlas/athena")
468 mr = gl_project.mergerequests.get(mr_url.split('/')[-1])
469 author = mr.author['username']
470 remote = f'https://:@gitlab.cern.ch:8443/{author}/athena.git'
471 local_branch = f'mr-{mr.iid}'
472
473 print("The next step is to update the MR with the new content i.e. the References.py file and the digest files.")
474 print(" IMPORTANT: before you do this, you must first make sure that the local repository is on same branch as the MR by doing:")
475 print(f" git fetch --no-tags {remote} {mr.source_branch}:{local_branch}")
476 print(f" git switch {local_branch}")
477 print(" git rebase upstream/main") # In case there have been any changes since the MR was created
478 print()
479
480 msg = 'Would you like to (locally) update digest ref files and/or versions in References.py?'
481 update_local_files = False
482 if input("%s (y/N) " % msg).lower() == 'y':
483 not_in_athena_dir = subprocess.call("git rev-parse --is-inside-work-tree", shell=True)
484 if not_in_athena_dir:
485 print('FATAL: You must run this script from within the athena directory.')
486 sys.exit(1)
487 update_local_files = True
488
489 commands = update_reference_files(not args.test_run, update_local_files)
490
491 if commands and args.test_run:
492 print()
493 print(' -> In test-run mode. In normal mode we would also have executed:')
494 for command in commands:
495 print(' ', command)
496 if not args.test_run:
497 print()
498 print("Finished! Before pushing, you might want to manually trigger an EOS to cvmfs copy here: https://atlas-jenkins.cern.ch/view/all/job/ART_data_eos2cvmfs/")
499 print("Then commit your changes and (force) push the updated branch to the author's remote:")
500 print(" git commit")
501 print(f" git push {remote} {local_branch}:{mr.source_branch}")
void print(char *figname, TCanvas *c1)
__init__(self, name, tag, mr, date, existing_ref, existing_version, new_version, new_version_directory, copied_file_path, diff, type)
std::vector< std::string > split(const std::string &s, const std::string &t=":")
Definition hcg.cxx:179
process_digest_change(text, ami_tag, mr_number, human_readable_date, test_name)
create_dir_and_copy_refs(test, actually_update=False)
process_diffpool_change(text, ami_tag, mr_number, human_readable_date, test_name)
summarise_failing_tests(check_for_duplicates=True)
update_reference_files(actually_update=True, update_local_files=False)