#!/usr/bin/env python """Common code for MAGE regression tests This module provides common code used by scripts in the MAGE regression test suite. Authors ------- Jeff Garretson Eric Winter """ # Import standard modules. import argparse import glob import os import subprocess import sys # Import 3rd-party modules. from bs4 import BeautifulSoup from slack_sdk import WebClient from slack_sdk.errors import SlackApiError # Import project modules. # Module constants # Name of Slack channels to use as message target SLACK_CHANNEL_NAME = '#kaijudev' SLACK_CHANNEL_ID = 'C01CGRFSUE9' SLACK_TEST_CHANNEL_NAME = '#kaijudev-testing' SLACK_TEST_CHANNEL_ID = 'C06Q1RAMJ03' def read_strip_blanks_and_comments(path): """Read a text file, stripping comments and blank lines. Read the specified text file, and strip out: 0. Leading and trailing whitespace 1. Lines containing only whitespace 2. Lines containing comments (First non-whitespace character is '#') 3. In-line comments (everything after '#' on a line) and resiual whitespace. Parameters ---------- path : str Path to text file to read Returns ------- lines : list of str List containing strings for each line in the file, with comments and blank lines removed Raises ------ None """ # Read the file. lines = open(path, encoding='utf-8').readlines() # Strip whitespace. lines = [line.strip() for line in lines] # Strip blank lines. lines = [line for line in lines if len(line) > 0] # Strip comment lines and residual trailing whitespace. lines = [line for line in lines if not line.startswith('#')] # Strip in-line comments. newlines = [] for line in lines: comment_pos = line.find('#') if comment_pos > -1: line = line[:comment_pos].rstrip() newlines.append(line) lines = newlines # Return the file contents as a list of strings. return lines def create_command_line_parser(description): """Create the command-line argument parser. Create the parser for command-line arguments. Parameters ---------- description : str Description of script Returns ------- parser : argparse.ArgumentParser Command-line argument parser for this script. Raises ------ None """ parser = argparse.ArgumentParser(description=description) parser.add_argument( '--debug', '-d', action='store_true', help='Print debugging output (default: %(default)s).' ) parser.add_argument( '--loud', '-l', action='store_true', help='Enable loud mode (post results to Slack) (default: %(default)s).' ) parser.add_argument( '--slack_on_fail', '-s', action='store_true', default=False, help='Only post to Slack on test failure (default: %(default)s).' ) parser.add_argument( '--test', '-t', action='store_true', help='Enable testing mode (default: %(default)s).' ) parser.add_argument( '--verbose', '-v', action='store_true', help='Print verbose output (default: %(default)s).' ) return parser def run_mage_test_script(test_script, args): """Run a single MAGE test script. Run a single MAGE test script. Parameters ---------- test_script : str Path to test script to run args : dict Options provided on command line Returns ------- cproc : subprocess.CompletedProcess Object containing results of running the command Raises ------ None """ # Assemble command-line flags to pass to the individual test # scripts. test_script_options = '' test_script_options += f" --account {args.account}" if args.all: test_script_options += ' -a' if args.debug: test_script_options += ' -d' if args.force: test_script_options += ' -f' if args.loud: test_script_options += ' -l' if args.test: test_script_options += ' -t' if args.verbose: test_script_options += ' -v' # Run the test script. cmd = f"python {test_script} {test_script_options}" cproc = subprocess.run(cmd, shell=True, check=True) return cproc def read_build_module_list_file(list_file): """Read a MAGE build module list file Read a MAGE build module list file. Parameters ---------- list_file : str Path to MAGE build module list/file list file to read Returns ------- module_or_file_names : list of str List of modules, or list of module list files cmake_environment : str Environment variables and values to set when invoking cmake with this module set cmake_options : str Command-line options for cmake when building MAGE with this module set Raises ------ None """ # Read the file, ignoring blank lines and comments. lines = read_strip_blanks_and_comments(list_file) # Extract the optional cmake environment variable settings, cmake_environment = '' label = 'CMAKE_ENV=' if lines[0].startswith(label): cmake_environment = lines[0][len(label):].rstrip() lines.pop(0) # Remove cmake environment line. # Extract the optional cmake command-line options, cmake_options = '' label = 'CMAKE_OPTIONS=' if lines[0].startswith(label): cmake_options = lines[0][len(label):].rstrip() lines.pop(0) # Remove cmake options line. # Save the remaining lines as a module or file list. module_or_file_names = [line.rstrip() for line in lines] # Return the file contents. return module_or_file_names, cmake_environment, cmake_options # ----------------------------------------------------------------------------- # Git utilities def git_get_branch_name(): """Fetch the name of the current git branch, Fetch the name of the current git branch. Parameters ---------- None Returns ------- git_branch_name : str Name of the current git branch Raises ------ None """ cmd = 'git symbolic-ref --short HEAD' cproc = subprocess.run(cmd, shell=True, check=True, text=True, capture_output=True) git_branch_name = cproc.stdout.rstrip() # Return the git branch name, return git_branch_name def git_pull(): """Pull the current branch from the git repository. Pull the current branch from the git repository. Parameters ---------- None Returns ------- git_pull_output : str Output from git pull command Raises ------ None """ cmd = 'git pull' cproc = subprocess.run(cmd, shell=True, check=True, text=True, capture_output=True) git_pull_output = cproc.stdout.rstrip() # Return the git pull output. return git_pull_output # ----------------------------------------------------------------------------- # Slack utilities def slack_create_client(): """Create a client for Slack communication. Create a client for Slack communication. Parameters ---------- None Returns ------- slack_client : slack_sdk.WebClient Client for Slack communication Raises ------ None """ # Get the Slack API token slack_token = os.environ['SLACK_BOT_TOKEN'] # Create the Slack client. slack_client = WebClient(token=slack_token) # Return the Slack client. return slack_client def slack_send_message(slack_client, message, thread_ts=None, is_test=False): """Send a message to Slack. Send a message to Slack. Errors during message sending are not considered to be fatal. Errors are caught, an error message is printed, and the program continues normally. Parameters ---------- slack_client : slack_sdk.WebClient Client for Slack communication message : str Message to send to Slack thread_ts : XXX, default None Set to desired Slack thread identifier (timestamp), if any is_test : bool If True, use the testing channel as the message target. Returns ------- response : XXX Response from Slack API when posting the message. Raises ------ None """ if is_test: channel = SLACK_TEST_CHANNEL_NAME else: channel = SLACK_CHANNEL_NAME try: response = slack_client.chat_postMessage( channel=channel, thread_ts=thread_ts, text=message, ) except SlackApiError as e: print('Sending message to Slack failed.', file=sys.stderr) response = e.response print(f"response = {response}", file=sys.stderr) return response def slack_send_image(slack_client, image_file_path, initial_comment='', thread_ts=None, is_test=False): """Send an image file to Slack. Send an image file to Slack. Parameters ---------- slack_client : slack_sdk.WebClient Client for Slack communication image_file_path : str Path to image file to send to Slack initial_comment : str Comment to include with image, default '' thread_ts : XXX, default None Set to desired Slack thread identifier (timestamp), if any is_test : bool If True, use the testing channel as the message target. Returns ------- response : XXX Response from Slack API when posting the image. Raises ------ None """ if is_test: channel_id = SLACK_TEST_CHANNEL_ID else: channel_id = SLACK_CHANNEL_ID try: response = slack_client.files_upload_v2( channel=channel_id, thread_ts=thread_ts, file=image_file_path, initial_comment=initial_comment, ) except SlackApiError as e: print('Sending image to Slack failed.', file=sys.stderr) response = e.response print(f"response = {response}", file=sys.stderr) return response def determine_first_gamera_result_file(xml_file: str): """Determine the path to the first file of GAMERA results for a run. Determine the path to the first file of GAMERA results for a run. Parameters ---------- xml_file : str Path to run XML file to read. Returns ------- first_file : str Path to first file of gamera results for this run. Raises ------ None """ # Read the run ID for this run. runid = extract_runid(xml_file) # Extract the results directory. results_dir = os.path.split(xml_file)[0] # Compute the name of the first GAMERA results file. The file to use # is the first file from the sorted list of files which match the # pattern. gam_h5_pattern = os.path.join(results_dir, f"{runid}_*.gam.h5") gam_h5_files = glob.glob(gam_h5_pattern) gam_h5_files.sort() first_file = gam_h5_files[0] # Return the path to the first GAMERA results file. return first_file def extract_runid(xml_file: str): """Extract the run ID from the MAGE XML file for a run. Extract the run ID from the MAGE XML file for a run. Parameters ---------- xml_file : str Path to MAGE XML file to read. Returns ------- runid : str Run ID for current run Raises ------ None """ # Read the XML file for this MAGE run. with open(xml_file, "r", encoding="utf-8") as f: xml_data = f.read() bs_data = BeautifulSoup(xml_data, "xml") # Extract the run ID. Kaiju_element = bs_data.find("Kaiju") Gamera_element = Kaiju_element.find("Gamera") sim_element = Gamera_element.find("sim") runid = sim_element.get("runid") # Return the run IDs. return runid def determine_voltron_result_file(xml_file: str): """Determine the path to the voltron results file for a run. Determine the path to the voltron results file for a run. Parameters ---------- xml_file : str Path to run XML file to read. Returns ------- voltron_file : str Path to file of voltron results for this run. Raises ------ None """ # Read the run ID for this run. runid = extract_runid(xml_file) # Compute the name of the voltron results file. results_dir = os.path.split(xml_file)[0] voltron_file = os.path.join(results_dir, f"{runid}.volt.h5") # Return the path to the voltron results file. return voltron_file def determine_remix_result_file(xml_file: str): """Determine the path to the remix results file for a run. Determine the path to the remix results file for a run. Parameters ---------- xml_file : str Path to run XML file to read. Returns ------- remix_file : str Path to file of remix results for this run. Raises ------ None """ # Read the run ID for this run. runid = extract_runid(xml_file) # Compute the name of the remix results file. results_dir = os.path.split(xml_file)[0] remix_file = os.path.join(results_dir, f"{runid}.mix.h5") # Return the path to the remix results file. return remix_file