diff --git a/.github/jobs/run_unit_tests.sh b/.github/jobs/run_unit_tests.sh index e866b2cc61..b85bac84fe 100755 --- a/.github/jobs/run_unit_tests.sh +++ b/.github/jobs/run_unit_tests.sh @@ -15,7 +15,7 @@ source ${MET_REPO_DIR}/.github/jobs/test_env_vars.sh echo "Running MET unit tests..." for testname in $TESTS_TO_RUN; do CMD_LOGFILE=/met/logs/unit_${testname}.log - time_command ${MET_TEST_BASE}/perl/unit.pl ${MET_TEST_BASE}/xml/unit_${testname}.xml + time_command ${MET_TEST_BASE}/python/unit.py ${MET_TEST_BASE}/xml/unit_${testname}.xml if [ $? != 0 ]; then echo "ERROR: Unit test ${testname} failed" cat /met/logs/unit_${testname}.log diff --git a/internal/test_unit/bin/unit_test.sh b/internal/test_unit/bin/unit_test.sh index 0f0493720f..0e2579464d 100755 --- a/internal/test_unit/bin/unit_test.sh +++ b/internal/test_unit/bin/unit_test.sh @@ -24,14 +24,14 @@ if [[ -z "${MET_TEST_MET_PYTHON_EXE}" ]] ; then export MET_TEST_MET_PYTHON_EXE=/usr/local/python3/bin/python3 fi -PERL_UNIT_OPTS="" +UNIT_OPTS="" for arg in $@; do - [ $arg == "-memchk" -o $arg == "memchk" ] && PERL_UNIT_OPTS="$PERL_UNIT_OPTS -memchk" - [ $arg == "-callchk" -o $arg == "callchk" ] && PERL_UNIT_OPTS="$PERL_UNIT_OPTS -callchk" + [ $arg == "-memchk" -o $arg == "memchk" ] && UNIT_OPTS="$UNIT_OPTS -memchk" + [ $arg == "-callchk" -o $arg == "callchk" ] && UNIT_OPTS="$UNIT_OPTS -callchk" done # Unit test script -PERL_UNIT=${MET_TEST_BASE}/perl/unit.pl +UNIT=${MET_TEST_BASE}/python/unit.py # Unit test XML UNIT_XML="unit_ascii2nc.xml \ @@ -107,15 +107,15 @@ UNIT_XML="${UNIT_XML} unit_ugrid.xml" for CUR_XML in ${UNIT_XML}; do echo - echo "CALLING: ${PERL_UNIT} $PERL_UNIT_OPTS ${MET_TEST_BASE}/xml/${CUR_XML}" + echo "CALLING: ${UNIT} $UNIT_OPTS ${MET_TEST_BASE}/xml/${CUR_XML}" echo - ${PERL_UNIT} $PERL_UNIT_OPTS ${MET_TEST_BASE}/xml/${CUR_XML} + ${UNIT} $UNIT_OPTS ${MET_TEST_BASE}/xml/${CUR_XML} RET_VAL=$? # Fail on non-zero return status if [ ${RET_VAL} != 0 ]; then echo - echo "ERROR: ${PERL_UNIT} ${CUR_XML} failed." + echo "ERROR: ${UNIT} ${CUR_XML} failed." echo echo "*** UNIT TESTS FAILED ***" echo diff --git a/internal/test_unit/python/unit.py b/internal/test_unit/python/unit.py new file mode 100755 index 0000000000..831a540b32 --- /dev/null +++ b/internal/test_unit/python/unit.py @@ -0,0 +1,389 @@ +#! /usr/bin/env python3 + +from datetime import datetime as dt +import logging +import os +from pathlib import Path +import re +import subprocess +import sys +import xml.etree.ElementTree as ET + +def unit(test_xml, file_log=None, cmd_only=False, noexit=False, memchk=False, callchk=False, log_overwrite=True): + """ + Parse a unit test xml file, run the associated tests, and display test results. + + Parameters + ----------- + test_xml : pathlike + path to file containing the unit test(s) to perform + file_log : pathlike, default None + if present, write output from each test to the specified file + cmd_only : bool, default False + if true, print the test commands but do not run them (overrides file_log) + noexit : bool, default False + if true, the unit tester will continue executing subsequent + tests when a test fails + memchk : bool, default False + if true, activate valgrind with memcheck + callchk : bool, default False + if true, activate valgrind with callcheck + log_overwrite : bool, default True + when true, if file_log points to an existing file, that file will be overwritten. + when false, new log records will be appended to the existing file. + """ + + # initialize logger + logger = logging.getLogger(__name__) + logger.setLevel(logging.DEBUG) + + # create/add console handler + ch = logging.StreamHandler() + ch.setLevel(logging.INFO) + logger.addHandler(ch) + + # create/add file handler + if file_log and not cmd_only: + if log_overwrite: + file_mode = 'w' + else: + file_mode = 'a' + fh = logging.FileHandler(file_log, mode=file_mode) + fh.setLevel(logging.DEBUG) + logger.addHandler(fh) + + # parse xml file + try: + test_root = ET.parse(test_xml) + except Exception as e: + logger.exception(f"ERROR: Unable to parse xml from {test_xml}") + raise + + # parse the children of the met_test element + if test_root.getroot().tag != 'met_test': + logger.error(f"ERROR: unexpected top-level element. Expected 'met_test', got '{test_root.tag}'") + sys.exit(1) + # read test_dir + try: + test_dir = test_root.find('test_dir').text + mgnc = repl_env(test_dir + '/bin/mgnc.sh') + mpnc = repl_env(test_dir + '/bin/mpnc.sh') + except Exception as e: + logger.warning(f"WARNING: unable to read test_dir from {test_xml}") + pass + + tests = build_tests(test_root) + + # determine the max length of the test names + # not used, unless format of test result display is changed + name_wid = max([len(test['name']) for test in tests]) + + VALGRIND_OPT_MEM ="--leak-check=full --show-leak-kinds=all --error-limit=no -v" + VALGRIND_OPT_CALL ="--tool=callgrind --dump-instr=yes --simulate-cache=yes --collect-jumps=yes" + + # run each test + for test in tests: + # # print the test name ... may want to change this to only if cmd_only=False + logger.debug("\n") + logger.info(f"TEST: {test['name']}") + + # # prepare the output space + output_keys = [key for key in test.keys() if key.startswith('out_')] + outputs = [output for key in output_keys for output in test[key]] + for output in outputs: + try: + Path(output).unlink() + except FileNotFoundError: + pass + except Exception as e: + logger.exception() + raise + output_dir = Path(output).parent + output_dir.mkdir(parents=True, exist_ok=True) #should error/warning be raised if dir already exists? + + # # set the test environment variables + set_envs = [] + if 'env' in test.keys(): + for key, val in sorted(test['env'].items()): + os.environ[key] = val + set_cmd = f"export {key}={val}" + logger.debug(set_cmd) + set_envs.append(set_cmd) + + # # build the text command + cmd = (test['exec'] + test['param']).strip() + + if memchk: + cmd = f"valgrind {VALGRIND_OPT_MEM} {cmd}" + elif callchk: + cmd = f"valgrind {VALGRIND_OPT_CALL} {cmd}" + + + # # if writing a command file, print the environment and command, then loop + # consider tying this into logging... + if cmd_only: + if 'env' in test.keys(): + for key, val in sorted(test['env'].items()): + print(f"export {key}={val}") + print(f"{cmd}") + if 'env' in test.keys(): + for key, val in sorted(test['env'].items()): + print(f"unset {key}") + print("\n") + + # # run and time the test command + else: + logger.debug(f"{cmd}") + t_start = dt.now() + cmd_return = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, shell=True) + t_elaps = dt.now() - t_start + + cmd_outs = cmd_return.stdout + logger.debug(f"{cmd_outs}") + logger.debug(f"Return code: {cmd_return.returncode}") + + # # check the return status and output files + ret_ok = not cmd_return.returncode + if ret_ok: + out_ok = True + + for filepath in test['out_pnc']: + result = subprocess.run([mpnc, '-v', filepath], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + cmd_outs += ("\n"+result.stdout) + logger.debug(result.stdout) + if result.returncode: + out_ok = False + + for filepath in test['out_gnc']: + result = subprocess.run([mgnc, '-v', filepath], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + cmd_outs += ("\n"+result.stdout) + logger.debug(result.stdout) + if result.returncode: + out_ok = False + + for filepath in test['out_stat']: + # check stat file exists and is nonzero size + try: + filesize = os.stat(filepath).st_size + if filesize==0: + cmd_outs += (f"\nERROR: stat file empty {filepath}\n") + out_ok = False + break + except FileNotFoundError: + cmd_outs += (f"\nERROR: stat file missing {filepath}\n") + logger.debug(result.stdout) + out_ok = False + break + # check stat file has non-header lines + with open(filepath) as f: + numlines = len([l for l in f.readlines() if not l.startswith('VERSION')]) + if numlines==0: + cmd_outs += (f"\nERROR: stat data missing from file {filepath}\n") + out_ok = False + + for filepath in test['out_ps']: + # check postscript file exists and is nonzero size + try: + filesize = os.stat(filepath).st_size + if filesize==0: + cmd_outs += (f"\nERROR: postscript file empty {filepath}\n") + out_ok = False + break + except FileNotFoundError: + cmd_outs += (f"\nERROR: postscript file missing {filepath}\n") + out_ok = False + break + # check for ghostscript errors + result = subprocess.run(['gs', '-sDEVICE=nullpage', '-dQUIET', '-dNOPAUSE', '-dBATCH', filepath]) + if result.returncode: + cmd_outs += (f"\nERROR: ghostscript error for postscript file {filepath}") + out_ok = False + + for filepath in test['out_exist']: + # check output file exists and is nonzero size + try: + filesize = os.stat(filepath).st_size + if filesize==0: + cmd_outs += (f"\nERROR: file empty {filepath}\n") + out_ok = False + break + except FileNotFoundError: + cmd_outs += (f"\nERROR: file missing when it should exist {filepath}\n") + out_ok = False + + for filepath in test['out_not_exist']: + # check output file doesn't exist + if os.path.isfile(filepath): + cmd_outs += (f"\nERROR: file exists when it should be missing {filepath}\n") + out_ok = False + + # # unset the test environment variables + unset_envs = [] + if 'env' in test.keys(): + for key, val in sorted(test['env'].items()): + del os.environ[key] + unset_cmd = f"unset {key}" + logger.debug(unset_cmd) + unset_envs.append(unset_cmd) + + # # print the test result + test_result = "pass" if (ret_ok and out_ok) else "FAIL" + logger.info(f"\t- {test_result} - \t{round(t_elaps.total_seconds(),3)} sec") + + # # on failure, print the problematic test and exit, if requested + if not (ret_ok and out_ok): + logger.info("\n".join(set_envs) + cmd + cmd_outs + "\n".join(unset_envs) + "\n") + if not noexit: + sys.exit(1) + + # clean up logger/handlers (to avoid duplicate logging when this function is looped) + logger.removeHandler(ch) + try: + logger.removeHandler(fh) + except NameError: + pass + + +def build_tests(test_root): + """ + Parse the test components. + + Take an ElementTree element extracted from a unit test xml file. + Return a list of all tests, where each test is represented as a dictionary, + with its keys representing each test component. + + Parameters + ---------- + test_root : ElementTree element + parsed from XML file containing the unit test(s) to perform + + Returns + ------- + test_list: + list of test dicts, containing test attributes parsed from xml object + + """ + + # define logger + logger = logging.getLogger(__name__) + + # find all tests in test_xml, and create a dictionary of attributes for each test + test_list = [] + for test_el in test_root.iter('test'): + test = {} + try: + test['name'] = test_el.attrib['name'] + except KeyError: + logger.error("ERROR: name attribute not found for test") + raise + + for el in test_el: + if (el.tag=='exec' or el.tag=='param'): + test[el.tag] = repl_env(el.text) + elif el.tag=='output': + test['out_pnc'] = [] + test['out_gnc'] = [] + test['out_stat'] = [] + test['out_ps'] = [] + test['out_exist'] = [] + test['out_not_exist'] = [] + output_names = { + 'point_nc' : 'out_pnc', + 'grid_nc' : 'out_gnc', + 'stat' : 'out_stat', + 'ps' : 'out_ps', + 'exist' : 'out_exist', + 'not_exist' : 'out_not_exist', + } + for output_el in el: + test[output_names[output_el.tag]].append(repl_env(output_el.text)) + + elif el.tag=='env': + env_dict = {} + for env_el in el: + try: + env_name = env_el.find('name').text + env_dict[env_name] = env_el.find('value').text + if not env_dict[env_name]: + env_dict[env_name] = '' + except AttributeError: + logger.error(f"ERROR: env pair in test \\{test['name']}\\ missing name or value") + raise + + test['env'] = env_dict + + # validate test format/details + expected_keys = ['exec', 'param', 'out_pnc', 'out_gnc', 'out_stat', 'out_ps', + 'out_exist', 'out_not_exist'] + for key in expected_keys: + if key not in test.keys(): + logger.error(f"ERROR: test {test['name']} missing {key} element") + sys.exit(1) + + test_list.append(test) + + return test_list + + +def repl_env(string_with_ref): + """ + Take a string with a placeholder for environment variable with syntax + ${ENV_NAME} and replace placeholder with corresponding value of environment + variable. + + Parameters + ---------- + string_with_ref : str + A string, generally path-like, that includes substring ${ENV_NAME} + + Returns + ------- + string_with_ref : str + The provided string with ${ENV_NAME} replaced by corresponding environment variable + """ + # define logger + logger = logging.getLogger(__name__) + + envar_ref_list = re.findall('\$\{\w+}', string_with_ref) + envar_ref_unique = [ + envar_ref_list[i] for i in list(range(len(envar_ref_list))) if ( + envar_ref_list[i] not in envar_ref_list[:i])] + + if len(envar_ref_unique)>0: + for envar_ref in envar_ref_unique: + envar_name = envar_ref[2:-1] + envar = os.getenv(envar_name) + if not envar: + logger.error(f"ERROR: environment variable {envar_name} not found") + string_with_ref = string_with_ref.replace(envar_ref, envar) + + return string_with_ref + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Run a unit test.") + parser.add_argument('test_xml', nargs='+') + parser.add_argument('-log', metavar='log_file', + help='if present, write output from each test to log_file') + parser.add_argument('-cmd', action='store_true', + help='if present, print the test commands but do not run them, overrides -log') + parser.add_argument('-memchk', action='store_true', + help='if present, activate valgrind with memcheck') + parser.add_argument('-callchk', action='store_true', + help='if present, activate valgrind with callcheck') + parser.add_argument('-noexit', action='store_true', + help='if present, the unit tester will continue executing subsequent tests when a test fails') + args = parser.parse_args() + + for i, xml in enumerate(args.test_xml): + if i==0: + new_log = True + else: + new_log = False + unit(test_xml=xml, file_log=args.log, cmd_only=args.cmd, noexit=args.noexit, memchk=args.memchk, callchk=args.callchk, + log_overwrite=new_log) + + diff --git a/internal/test_unit/xml/unit_point_stat.xml b/internal/test_unit/xml/unit_point_stat.xml index b57d756a14..aea2b8e042 100644 --- a/internal/test_unit/xml/unit_point_stat.xml +++ b/internal/test_unit/xml/unit_point_stat.xml @@ -18,6 +18,8 @@ + &TEST_DIR; + true &MET_BIN;/point_stat diff --git a/internal/test_unit/xml/unit_python.xml b/internal/test_unit/xml/unit_python.xml index 0640aafd74..3bf4c6521f 100644 --- a/internal/test_unit/xml/unit_python.xml +++ b/internal/test_unit/xml/unit_python.xml @@ -162,9 +162,9 @@ &OUTPUT_DIR;/python/mode_python_mixed_300000L_20120410_180000V_060000A.ps - &OUTPUT_DIR;/python/mode_python_mixed_300000L_20120410_180000V_060000A_cts.txt - &OUTPUT_DIR;/python/mode_python_mixed_300000L_20120410_180000V_060000A_obj.txt - &OUTPUT_DIR;/python/mode_python_mixed_300000L_20120410_180000V_060000A_obj.nc + &OUTPUT_DIR;/python/mode_python_mixed_300000L_20120410_180000V_060000A_cts.txt + &OUTPUT_DIR;/python/mode_python_mixed_300000L_20120410_180000V_060000A_obj.txt + &OUTPUT_DIR;/python/mode_python_mixed_300000L_20120410_180000V_060000A_obj.nc @@ -182,9 +182,9 @@ &OUTPUT_DIR;/python/mode_python_120000L_20050807_120000V_120000A.ps - &OUTPUT_DIR;/python/mode_python_120000L_20050807_120000V_120000A_obj.txt - &OUTPUT_DIR;/python/mode_python_120000L_20050807_120000V_120000A_cts.txt - &OUTPUT_DIR;/python/mode_python_120000L_20050807_120000V_120000A_obj.nc + &OUTPUT_DIR;/python/mode_python_120000L_20050807_120000V_120000A_obj.txt + &OUTPUT_DIR;/python/mode_python_120000L_20050807_120000V_120000A_cts.txt + &OUTPUT_DIR;/python/mode_python_120000L_20050807_120000V_120000A_obj.nc @@ -200,8 +200,7 @@ -outdir &OUTPUT_DIR;/python -v 1 - &OUTPUT_DIR;/python/grid_stat_python_mixed_120000L_20120409_120000V.stat - &OUTPUT_DIR;/python/grid_stat_python_mixed_120000L_20120409_120000V_pairs.nc + &OUTPUT_DIR;/python/grid_stat_python_mixed_120000L_20120409_120000V_pairs.nc @@ -218,7 +217,7 @@ -outdir &OUTPUT_DIR;/python -v 1 - &OUTPUT_DIR;/python/grid_stat_python_120000L_20050807_120000V_pairs.nc + &OUTPUT_DIR;/python/grid_stat_python_120000L_20050807_120000V_pairs.nc @@ -237,7 +236,7 @@ -outdir &OUTPUT_DIR;/python -v 1 - &OUTPUT_DIR;/python/point_stat_python_120000L_20120409_120000V.stat + &OUTPUT_DIR;/python/point_stat_120000L_20050807_120000V.stat @@ -255,7 +254,7 @@ &OUTPUT_DIR;/python/wavelet_stat_python_120000L_20050807_120000V.stat - &OUTPUT_DIR;/python/wavelet_stat_python_120000L_20050807_120000V_isc.txt + &OUTPUT_DIR;/python/wavelet_stat_python_120000L_20050807_120000V_isc.txt &OUTPUT_DIR;/python/wavelet_stat_python_120000L_20050807_120000V.nc &OUTPUT_DIR;/python/wavelet_stat_python_120000L_20050807_120000V.ps @@ -274,7 +273,7 @@ &OUTPUT_DIR;/python/wavelet_stat_python_mixed_120000L_20050807_120000V.stat - &OUTPUT_DIR;/python/wavelet_stat_python_mixed_120000L_20050807_120000V_isc.txt + &OUTPUT_DIR;/python/wavelet_stat_python_mixed_120000L_20050807_120000V_isc.txt &OUTPUT_DIR;/python/wavelet_stat_python_mixed_120000L_20050807_120000V.nc &OUTPUT_DIR;/python/wavelet_stat_python_mixed_120000L_20050807_120000V.ps diff --git a/internal/test_unit/xml/unit_ref_config_lead_12.xml b/internal/test_unit/xml/unit_ref_config_lead_12.xml index 989e548da5..5945ff2fdc 100644 --- a/internal/test_unit/xml/unit_ref_config_lead_12.xml +++ b/internal/test_unit/xml/unit_ref_config_lead_12.xml @@ -79,7 +79,7 @@ \ -subtract \ &DATA_DIR_MODEL;/grib1/ref_config/2011090200/AFWAv3.4_Noahv3.3/postprd/wrfprs_012.tm00 12 \ - &DATA_DIR_MODEL;/grib1/ref_config/2011090200/AFWAv3.4_Noahv3.3/postprd/wrfprs_009.tm00 9 \ + &DATA_DIR_MODEL;/grib1/ref_config/2011090200/AFWAv3.4_Noahv3.3/postprd/wrfprs_009.tm00 9 \ &OUTPUT_DIR;/ref_config_lead_12/pcp_combine/wrf/wrfpcp03_012.nc diff --git a/internal/test_unit/xml/unit_tc_diag.xml b/internal/test_unit/xml/unit_tc_diag.xml index e0e1686718..1f75454ac0 100644 --- a/internal/test_unit/xml/unit_tc_diag.xml +++ b/internal/test_unit/xml/unit_tc_diag.xml @@ -33,9 +33,9 @@ -v 2 - &OUTPUT_DIR;/tc_diag/sal092022_gfso_doper_2022092400_cyl_grid_parent.nc - &OUTPUT_DIR;/tc_diag/sal092022_gfso_doper_2022092400_diag.nc - &OUTPUT_DIR;/tc_diag/sal092022_gfso_doper_2022092400_diag.dat + &OUTPUT_DIR;/tc_diag/sal092022_gfso_doper_2022092400_cyl_grid_parent.nc + &OUTPUT_DIR;/tc_diag/sal092022_gfso_doper_2022092400_diag.nc + &OUTPUT_DIR;/tc_diag/sal092022_gfso_doper_2022092400_diag.dat diff --git a/internal/test_unit/xml/unit_ugrid.xml b/internal/test_unit/xml/unit_ugrid.xml index 80a6a53360..5f6e517a7c 100644 --- a/internal/test_unit/xml/unit_ugrid.xml +++ b/internal/test_unit/xml/unit_ugrid.xml @@ -20,6 +20,9 @@ + &TEST_DIR; + true + &MET_BIN;/grid_stat @@ -33,7 +36,7 @@ &OUTPUT_DIR;/grid_stat_ugrid/grid_stat_UGRID_MPAS_OUT_TO_GRID_000000L_20120409_120000V.stat - &OUTPUT_DIR;/grid_stat_ugrid/grid_stat_UGRID_MPAS_OUT_TO_GRID_000000L_20120409_120000V_pairs.nc + &OUTPUT_DIR;/grid_stat_ugrid/grid_stat_UGRID_MPAS_OUT_TO_GRID_000000L_20120409_120000V_pairs.nc @@ -51,7 +54,7 @@ &OUTPUT_DIR;/grid_stat_ugrid/grid_stat_UGRID_MPAS_DIAG_000000L_20120409_120000V.stat - &OUTPUT_DIR;/grid_stat_ugrid/grid_stat_UGRID_MPAS_DIAG_000000L_20120409_120000V_pairs.nc + &OUTPUT_DIR;/grid_stat_ugrid/grid_stat_UGRID_MPAS_DIAG_000000L_20120409_120000V_pairs.nc