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 affects standard outputs in test' in text or
110 'ERROR Your change breaks the frozen tier0 policy in test' in text or
111 'ERROR Your change breaks the frozen derivation policy in test' in text):
112 # DiffPool change
113 failing_tests[branch].append(process_diffpool_change(text, ami_tag, mr_number, human_readable_date, test_name))
114
115 return
116
117def process_diffpool_change(text, ami_tag, mr_number, human_readable_date, test_name):
118 eos_path_root = '/eos/atlas/atlascerngroupdisk/data-art/grid-input/WorkflowReferences/'
119
120 # Copied file path
121 # e.g. from ERROR Copied '../SimulationRun3FullSim/run_s4006/myHITS.pool.root' to '/eos/atlas/atlascerngroupdisk/proj-ascig/gitlabci/MR63410_a84345c776e93f0d7f25d00c9e91e35bcb965d09/SimulationRun3FullSimChecks'
122 copied_file_match = re.search(r'^ERROR Copied.*', text, flags=re.MULTILINE)
123 if not copied_file_match:
124 print("FATAL: Could not find matching copied file")
125 sys.exit(1)
126 copied_file_path = copied_file_match.group().split('to')[1].strip().strip("'").strip("&#x27;")+'/'
127
128 # Reference file paths
129 ref_file_match = re.search(r'INFO Reading the reference file from location.*', text)
130 if not ref_file_match:
131 print("FATAL: Could not find matching reference file")
132 sys.exit(1)
133
134 ref_file_path = ref_file_match.group().split('location')[1].strip()
135 existing_version_number= ref_file_path.split('/')[-2]
136 branch = ref_file_path.split('/')[-4]
137 new_version_number = 'v'+str(int(existing_version_number[1:])+1)
138 new_version_directory = eos_path_root+branch+'/'+ami_tag+'/'+new_version_number
139 old_version_directory = eos_path_root+branch+'/'+ami_tag+'/'+existing_version_number
140 # Copied file path
141 # e.g. from ERROR Copied '../SimulationRun3FullSim/run_s4006/myHITS.pool.root' to '/eos/atlas/atlascerngroupdisk/proj-ascig/gitlabci/MR63410_a84345c776e93f0d7f25d00c9e91e35bcb965d09/SimulationRun3FullSimChecks'
142 copied_file_match = re.search(r'^ERROR Copied.*', text, flags=re.MULTILINE)
143 if not copied_file_match:
144 print("FATAL: Could not find matching copied file")
145 sys.exit(1)
146
147 # Sanity checks
148 ami_tag_check = ref_file_path.split('/')[-3].strip()
149 if ami_tag_check!=ami_tag:
150 print('FATAL: Sanity check: "{}" from reference file path "{}" does not match ami tag "{}" extracted previously.'.format(ami_tag_check, ref_file_path, ami_tag))
151 sys.exit(1)
152
153
154 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')
155 return test
156
157def process_digest_change(text, ami_tag, mr_number, human_readable_date, test_name):
158 # Some things aren't so relevant for digest changes
159 existing_version_number = None
160 new_version_directory = None
161 copied_file_path = None
162 new_version_number=None
163
164 # differs from the reference 'q447_AOD_digest.ref' (<):
165 ref_file_match = re.search(
166 r"differs from the reference (?:'|&#x27;)([^'&]+?)(?:'|&#x27;)",
167 text
168 )
169 if not ref_file_match:
170 print("FATAL: Could not find matching reference file")
171 sys.exit(1)
172 ref_file_path = ref_file_match.group(1)
173
174 diff_lines = []
175 diff_started = False # Once we hit the beginning of the diff, we start recording
176 # Diff starts with e.g.
177 # ERROR The output 'q449_AOD_digest.txt' (>) differs from the reference 'q449_AOD_digest.ref' (<):
178 # and ends with next INFO line
179
180 for line in text.split('\n'):
181 if 'differs from the reference' in line:
182 # Start of the diff
183 diff_started = True
184 elif diff_started:
185 if 'INFO' in line:
186 # End of the diff
187 break
188 elif len(line)>0:
189 diff_lines.append(html.unescape(line))
190
191 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')
192 return test
193
194def update_reference_files(actually_update=True, update_local_files=False):
195 print()
196 print('Updating reference files')
197 print('========================')
198 commands = []
199 for branch, tests in failing_tests.items():
200 for test in tests:
201 print('Processing test: {} on branch {}'.format(test.name, branch))
202 if test.type == 'DiffPool':
203 if test.shared_ref:
204 print(' * This is a DiffPool test but uses a shared reference. No update needed.')
205 continue
206
207 print(' * This is a DiffPool test, and currently has version {} of {}. Will update References.py with new version.'.format(test.existing_version, test.tag))
208 if actually_update:
209 print(' -> The new version is: {}. Creating directory and copying files on EOS now.'.format(test.new_version))
210 create_dir_and_copy_refs(test, True)
211 else:
212 # We will print these later, so we can sanity check them when in test mode
213 commands.extend(create_dir_and_copy_refs(test, False))
214 # Remove any duplicates, whilst preserving the order
215 commands = list(dict.fromkeys(commands))
216
217 # Now, update local References.py file
218 if update_local_files:
219 data = []
220 if debug:
221 print ('Updating local References.py file with new version {} for tag {}'.format(test.new_version, test.tag))
222 line_found = False
223 with open('Tools/WorkflowTestRunner/python/References.py', 'r') as f:
224 lines = f.readlines()
225 for line in lines:
226 if test.tag in line:
227 if test.existing_version in line:
228 line = line.replace(test.existing_version, test.new_version)
229 else:
230 print('')
231 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='')
232 print('** Are you sure your branch is up-to-date with main? We cannot update an older version of References.py!')
233 line_found = True
234 data.append(line)
235
236 if not line_found:
237 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))
238
239 with open('Tools/WorkflowTestRunner/python/References.py', 'w') as f:
240 f.writelines(data)
241 elif test.type == 'Digest' and update_local_files:
242 print(' * This is a Digest test. Need to update reference file {}.'.format(test.existing_ref))
243 data = []
244
245 diff_line=0 # We will use this to keep track of which line in the diff we are on
246 digest_old = [line for line in test.diff if line.startswith('<')]
247 digest_new = [line for line in test.diff if line.startswith('>')]
248
249 with open('Tools/PROCTools/data/'+test.existing_ref, 'r') as f:
250 lines = f.readlines()
251 for current_line, line in enumerate(lines):
252 split_curr_line = line.split()
253 if (split_curr_line[0] == 'run'): # Skip header line
254 data.append(line)
255 continue
256
257 # So, we expect first two numbers to be run/event respectively
258 if (not split_curr_line[0].isnumeric()) or (not split_curr_line[1].isnumeric()):
259 print('FATAL: Found a line in current digest which does not start with run/event numbers: {}'.format(line))
260 sys.exit(1)
261
262 split_old_diff_line = digest_old[diff_line].split()
263 split_old_diff_line.pop(0) # Remove the < character
264 split_new_diff_line = digest_new[diff_line].split()
265 split_new_diff_line.pop(0) # Remove the > character
266
267 # Let's check to see if the run/event numbers match
268 if split_curr_line[0] == split_old_diff_line[0] and split_curr_line[1] == split_old_diff_line[1]:
269 # Okay so run/event numbers match. Let's just double-check it wasn't already updated
270 if split_curr_line!=split_old_diff_line:
271 print('FATAL: It seems like this line was already changed.')
272 print('Line we expected: {}'.format(test.old_diff_lines[diff_line]))
273 print('Line we got : {}'.format(line))
274 sys.exit(1)
275
276 # Check if the new run/event numbers match
277 if split_curr_line[0] == split_new_diff_line[0] and split_curr_line[1] == split_new_diff_line[1]:
278 #Replace the existing line with the new one, making sure we right align within 12 characters
279 data.append("".join(["{:>12}".format(x) for x in split_new_diff_line])+ '\n')
280 if ((diff_line+1)<len(digest_old)):
281 diff_line+=1
282 continue
283
284 # Otherwise, we just keep the existing line
285 data.append(line)
286
287 print(' -> Updating PROCTools digest file {}'.format(test.existing_ref))
288 with open('Tools/PROCTools/data/'+test.existing_ref, 'w') as f:
289 f.writelines(data)
290 elif test.type == 'Content' and update_local_files:
291 print(' * This is a Content test. Need to update reference file {}.'.format(test.existing_ref))
292 subprocess.run(f'patch --quiet Tools/PROCTools/data/{test.existing_ref}',
293 input='\n'.join(test.diff)+'\n',
294 text=True, shell=True, check=True)
295
296 return commands
297
298
299def create_dir_and_copy_refs(test, actually_update=False):
300 """
301 If called with actually_update=False, this function will return a list of commands which would have been executed.
302 """
303 commands = []
304
305 # Nothing to do if test uses a shared reference
306 if test.shared_ref is True:
307 return commands
308
309 if test.new_version_directory not in dirs_created:
310 commands.append("mkdir -p " + test.new_version_directory)
311 dirs_created.append(test.new_version_directory)
312
313 # Copy new directory first, then copy old (in case the new MR did not touch all files)
314 # Important! Use no-clobber for second copy or we will overwrite the new data with old!
315 commands.append("cp " + test.copied_file_path + "* "+ test.new_version_directory+"/")
316 commands.append("cp -n " + test.existing_ref + "/* "+ test.new_version_directory+"/")
317 if actually_update:
318 print(' -> Copying files from {} to {}'.format(test.copied_file_path, test.new_version_directory))
319 try:
320 for command in commands:
321 try:
322 subprocess.call( command, shell=True)
323 except Exception as e:
324 print('Command failed due to:', e)
325 print('Do you have EOS available on this machine?')
326 except Exception as e:
327 print('FATAL: Unable to copy files due to:', e)
328 sys.exit(1)
329
330 f = open(test.new_version_directory+'/info.txt', 'w')
331 f.write('Merge URL: https://gitlab.cern.ch/atlas/athena/-/merge_requests/{}\n'.format(test.mr))
332 f.write('Date: {}\n'.format(test.date))
333 f.write('AMI: {}\n'.format(test.tag))
334 f.write('Test name: {}\n'.format(test.name))
335 f.write('Files copied from: {}\n'.format(test.copied_file_path))
336 f.close()
337
338 return commands
339
341 # Each list entry is one column in the table.
342 for row in data:
343 if ('ERROR' in row[0]):
344 process_log_file(strip_url(row[2]), branch = row[1], test_name=strip_href(row[2]))
345
346def strip_url(href):
347 url = href[href.find('"')+1:] # Strip everything up to first quotation mark
348 url = url[:url.find('"')]
349 return url
350
351def strip_href(href):
352 value = href[href.find('>')+1:] # Strip everything up to first >
353 value = value[:value.find('<')]
354 return value
355
357 # Each entry is one column in the table. 11th is the tests column.
358 # URL to tests page is in form:
359 # <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>
360 test_counts = strip_href(project[11])
361 # This is e.g. '0 (0)'
362 test_error_counts = int(test_counts.split(' ')[0])
363 if test_error_counts > 0:
364 # Okay, we have an error!
365 project_url = 'https://bigpanda.cern.ch'+strip_url(project[11])
366 headers = {'Accept': 'application/json'}
367 r = requests.get(project_url+'&json', headers=headers)
368 data = r.json()["rows_s"]
369 process_CI_Tests_json(data[1:])
370
372 headers = {'Accept': 'application/json'}
373 r = requests.get(url+'&json', headers=headers)
374 data = r.json()["rows_s"]
375 # First row is header.
376 # 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'
377 for project in data[1:]:
379
380
382 # Tests that are allowed to use the same reference. The key is the test that uses the
383 # reference of its value.
384 shared_refs = {
385 'CITest_DerivationRun2Data_PHYS_MT-test': 'CITest_DerivationRun2Data_PHYS-test',
386 'CITest_DerivationRun2MC_PHYS_MT-test': 'CITest_DerivationRun2MC_PHYS-test',
387 'CITest_DerivationRun3Data_PHYS_MT-test': 'CITest_DerivationRun3Data_PHYS-test',
388 'CITest_DerivationRun3MC_PHYS_MT-test': 'CITest_DerivationRun3MC_PHYS-test',
389 'CITest_DerivationRun2Data_PHYSLITE_MT-test': 'CITest_DerivationRun2Data_PHYSLITE-test',
390 'CITest_DerivationRun2MC_PHYSLITE_MT-test': 'CITest_DerivationRun2MC_PHYSLITE-test',
391 'CITest_DerivationRun3Data_PHYSLITE_MT-test': 'CITest_DerivationRun3Data_PHYSLITE-test',
392 'CITest_DerivationRun3MC_PHYSLITE_MT-test': 'CITest_DerivationRun3MC_PHYSLITE-test',
393 }
394
395 for branch,tests in failing_tests.items():
396 # Create dictionary of ref vs tests
397 refs = defaultdict(list) # tag : [test,...]
398 for test in tests:
399 refs[test.tag].append(test)
400
401 for r, dups in refs.items():
402 if len(dups) <= 1:
403 continue
404 for test in dups:
405 # Mark test as having shared ref if itself and its reference is in the list
406 if (name := shared_refs.get(test.name)) and any(name==t.name for t in dups):
407 test.shared_ref = True
408
409
410def summarise_failing_tests(check_for_duplicates = True):
411 print('Summary of tests which need work:')
412
413 if not failing_tests:
414 print(" -> None found. Aborting.")
415 return None
416
417 mr = None
418 reference_folders = []
419 for branch,tests in failing_tests.items():
420 print (' * Branch: {}'.format(branch))
421 for test in tests:
422 print(' - ', test)
423 if test.type == 'DiffPool':
424 if not test.new_version_directory:
425 print('FATAL: No path to "new version" for test {} of type DiffPool.'.format(test.name))
426 sys.exit(1)
427
428 if os.path.exists(test.new_version_directory):
429 msg = f'WARNING: The directory {test.new_version_directory} already exists. Are you sure you want to overwrite the existing references?'
430 if input("%s (y/N) " % msg).lower() != 'y':
431 sys.exit(1)
432
433 if (not test.shared_ref and test.existing_ref not in reference_folders):
434 reference_folders.append(test.existing_ref)
435 elif check_for_duplicates and not test.shared_ref:
436 print('FATAL: Found two tests which both change the same reference file: {}, which is not supported.'.format(test.existing_ref))
437 print('Consider running again in --test-run mode, to get a copy of the copy commands that could be run.')
438 print('The general advice is to take the largest file (since it will have the most events).')
439 sys.exit(1)
440 mr = test.mr
441 return 'https://gitlab.cern.ch/atlas/athena/-/merge_requests/'+mr
442
443if __name__ == '__main__':
444 parser = argparse.ArgumentParser(description=__doc__,
445 formatter_class=argparse.RawDescriptionHelpFormatter)
446 parser.add_argument('url', help='URL to CITest (put in quotes))')
447 parser.add_argument('--test-run',help='Update local text files, but do not actually touch EOS.', action='store_true')
448 args = parser.parse_args()
449 print('Update reference files for URL: {}'.format(args.url))
450
451 if not args.url.startswith(('http://', 'https://')):
452 print('invalid url - should start with http:// or https://')
453 print(args.url)
454 print('Aborting.')
455 sys.exit(1)
456
457 if args.test_run:
458 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).')
459
460 print('========================')
463 mr_url = summarise_failing_tests(not args.test_run)
464 if not mr_url:
465 sys.exit(1)
466 print('========================')
467
468 # Retrieve MR infos:
469 gl_project = gitlab.Gitlab("https://gitlab.cern.ch").projects.get("atlas/athena")
470 mr = gl_project.mergerequests.get(mr_url.split('/')[-1])
471 author = mr.author['username']
472 remote = f'https://:@gitlab.cern.ch:8443/{author}/athena.git'
473 local_branch = f'mr-{mr.iid}'
474
475 print("The next step is to update the MR with the new content i.e. the References.py file and the digest files.")
476 print(" IMPORTANT: before you do this, you must first make sure that the local repository is on same branch as the MR by doing:")
477 print(f" git fetch --no-tags {remote} {mr.source_branch}:{local_branch}")
478 print(f" git switch {local_branch}")
479 print(" git rebase upstream/main") # In case there have been any changes since the MR was created
480 print()
481
482 msg = 'Would you like to (locally) update digest ref files and/or versions in References.py?'
483 update_local_files = False
484 if input("%s (y/N) " % msg).lower() == 'y':
485 not_in_athena_dir = subprocess.call("git rev-parse --is-inside-work-tree", shell=True)
486 if not_in_athena_dir:
487 print('FATAL: You must run this script from within the athena directory.')
488 sys.exit(1)
489 update_local_files = True
490
491 commands = update_reference_files(not args.test_run, update_local_files)
492
493 if commands and args.test_run:
494 print()
495 print(' -> In test-run mode. In normal mode we would also have executed:')
496 for command in commands:
497 print(' ', command)
498 if not args.test_run:
499 print()
500 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/")
501 print("Then commit your changes and (force) push the updated branch to the author's remote:")
502 print(" git commit")
503 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)