From bc8143e5010ed4158efc16c37ae4906b2f49137d Mon Sep 17 00:00:00 2001 From: John Alex Date: Wed, 5 Aug 2026 10:45:57 -0700 Subject: [PATCH 1/2] feat(compare_namelists): add TOML support for compare_namelists and mizuRoute baseline comparison --- CIME/case/case_cmpgen_namelists.py | 58 ++++++-- CIME/compare_namelists.py | 20 +++ .../test_unit_cmpgen_namelists_mizuroute.py | 124 ++++++++++++++++++ CIME/tests/test_unit_compare_namelists.py | 66 ++++++++++ setup.py | 1 + 5 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 CIME/tests/test_unit_cmpgen_namelists_mizuroute.py create mode 100644 CIME/tests/test_unit_compare_namelists.py diff --git a/CIME/case/case_cmpgen_namelists.py b/CIME/case/case_cmpgen_namelists.py index 6a2579ada35..e505ca21d25 100644 --- a/CIME/case/case_cmpgen_namelists.py +++ b/CIME/case/case_cmpgen_namelists.py @@ -16,17 +16,16 @@ logger = logging.getLogger(__name__) -def _do_full_nl_comp(case, test, compare_name, baseline_root=None): - test_dir = case.get_value("CASEROOT") - casedoc_dir = os.path.join(test_dir, "CaseDocs") - baseline_root = ( - case.get_value("BASELINE_ROOT") if baseline_root is None else baseline_root - ) +def _do_full_nl_comp(caseroot, test, compare_name, baseline_root): + casedoc_dir = os.path.join(caseroot, "CaseDocs") all_match = True - baseline_dir = os.path.join(baseline_root, compare_name, test) - baseline_casedocs = os.path.join(baseline_dir, "CaseDocs") + case_baseline_dir = os.path.join(baseline_root, compare_name, test) + baseline_casedocs = os.path.join(case_baseline_dir, "CaseDocs") + # Generic format migration transition check (e.g. .control <-> .toml). + # During format migration, if a case generates both formats (stem.control and stem.toml), + # allow comparing against whichever format exists in the baseline directory. # Start off by comparing everything in CaseDocs except a few arbitrary files (ugh!) # TODO: Namelist files should have consistent suffix all_items_to_compare = [ @@ -39,14 +38,50 @@ def _do_full_nl_comp(case, test, compare_name, baseline_root=None): ] comments = "NLCOMP\n" + casedoc_files_lower = ( + {f.lower(): f for f in os.listdir(casedoc_dir)} + if os.path.exists(casedoc_dir) + else {} + ) + for item in all_items_to_compare: + filebase = os.path.basename(item) baseline_counterpart = os.path.join( baseline_casedocs if os.path.dirname(item).endswith("CaseDocs") - else baseline_dir, - os.path.basename(item), + else case_baseline_dir, + filebase, ) + baseline_dir = os.path.dirname(baseline_counterpart) + + # Generic format transition check (.control <-> .toml) + stem, ext = os.path.splitext(filebase) + ext_lower = ext.lower() + if ext_lower in [".control", ".toml"]: + alt_ext = ".toml" if ext_lower == ".control" else ".control" + alt_casedoc_key = (stem + alt_ext).lower() + has_both_in_casedocs = alt_casedoc_key in casedoc_files_lower + + if has_both_in_casedocs and os.path.exists(baseline_dir): + baseline_files_lower = { + f.lower(): f for f in os.listdir(baseline_dir) + } + alt_baseline_key = (stem + alt_ext).lower() + has_alt_in_baseline = alt_baseline_key in baseline_files_lower + + if ext_lower == ".control" and has_alt_in_baseline: + # Skip legacy .control if baseline has .toml (prefer .toml) + continue + if ( + ext_lower == ".toml" + and not os.path.exists(baseline_counterpart) + and has_alt_in_baseline + ): + # Skip .toml if baseline has legacy .control + continue + if not os.path.exists(baseline_counterpart): + comments += "Missing baseline namelist '{}'\n".format(baseline_counterpart) all_match = False else: @@ -165,8 +200,9 @@ def case_cmpgen_namelists( success = True output = "" if compare: + b_root = baseline_root if baseline_root is not None else self.get_value("BASELINE_ROOT") success, output = _do_full_nl_comp( - self, test_name, compare_name, baseline_root + caseroot, test_name, compare_name, b_root ) if not success and ts.get_status(RUN_PHASE) is not None: run_warn = """NOTE: It is not necessarily safe to compare namelists after RUN diff --git a/CIME/compare_namelists.py b/CIME/compare_namelists.py index 6e98a7df9a5..41b5aa18976 100644 --- a/CIME/compare_namelists.py +++ b/CIME/compare_namelists.py @@ -813,6 +813,24 @@ def _compare_yamls(gold_file, compare_file, case): return normalized_dict_compare("top-level", data1, data2, case) +############################################################################### +def _compare_tomls(gold_file, compare_file, case): + ############################################################################### + """ + Compare contents of two TOML files + """ + try: + import tomllib + except ImportError: + import tomli as tomllib + + with open(gold_file, "rb") as f1, open(compare_file, "rb") as f2: + data1 = tomllib.load(f1) + data2 = tomllib.load(f2) + + return normalized_dict_compare("top-level", data1, data2, case) + + ############################################################################### def compare_namelist_files(gold_file, compare_file, case=None): ############################################################################### @@ -824,6 +842,8 @@ def compare_namelist_files(gold_file, compare_file, case=None): if gold_file.endswith(".yaml") or gold_file.endswith(".yml"): comments = _compare_yamls(gold_file, compare_file, case) + elif gold_file.endswith(".toml"): + comments = _compare_tomls(gold_file, compare_file, case) else: gold_namelists = _parse_namelists(open(gold_file, "r").readlines(), gold_file) comp_namelists = _parse_namelists( diff --git a/CIME/tests/test_unit_cmpgen_namelists_mizuroute.py b/CIME/tests/test_unit_cmpgen_namelists_mizuroute.py new file mode 100644 index 00000000000..e5492456e4e --- /dev/null +++ b/CIME/tests/test_unit_cmpgen_namelists_mizuroute.py @@ -0,0 +1,124 @@ +# Unit tests for mizuRoute baseline namelist comparison transition logic. +import unittest +import tempfile +import os +import shutil + +from CIME.case.case_cmpgen_namelists import _do_full_nl_comp + +class TestMizuRouteNamelistCompare(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.mkdtemp() + self.caseroot = os.path.join(self.tempdir, "caseroot") + self.casedocs = os.path.join(self.caseroot, "CaseDocs") + self.baseline_root = os.path.join(self.tempdir, "baselines") + + self.compare_name = "test_cmp" + self.test_name = "test_case" + + self.baseline_dir = os.path.join(self.baseline_root, self.compare_name, self.test_name) + self.baseline_casedocs = os.path.join(self.baseline_dir, "CaseDocs") + + os.makedirs(self.casedocs) + os.makedirs(self.baseline_casedocs) + + def tearDown(self): + shutil.rmtree(self.tempdir, ignore_errors=True) + + def test_baseline_has_only_control(self): + """Verify comparison passes when baseline contains only legacy mizuRoute.control file while CaseDocs contains both.""" + # Setup generated CaseDocs with both + with open(os.path.join(self.casedocs, "mizuRoute.control"), "w") as f: + f.write("route_opt 5\n") + with open(os.path.join(self.casedocs, "mizuroute.toml"), "w") as f: + f.write("[route_opt]\nvalue = 5\n") + + # Setup baseline with ONLY .control + with open(os.path.join(self.baseline_casedocs, "mizuRoute.control"), "w") as f: + f.write("route_opt 5\n") + + match, comments = _do_full_nl_comp(self.caseroot, self.test_name, self.compare_name, self.baseline_root) + + # Baseline lacks mizuroute.toml, but transition logic in _do_full_nl_comp tolerates missing twin config file + self.assertTrue(match) + + def test_baseline_has_both(self): + """Verify comparison prefers mizuroute.toml and ignores mizuRoute.control when both exist in baseline.""" + # Setup generated CaseDocs with both + with open(os.path.join(self.casedocs, "mizuRoute.control"), "w") as f: + f.write("route_opt 5\n") + with open(os.path.join(self.casedocs, "mizuroute.toml"), "w") as f: + f.write("[route_opt]\nvalue = 5\n") + + # Setup baseline with BOTH, but with legacy .control having mismatching content + with open(os.path.join(self.baseline_casedocs, "mizuRoute.control"), "w") as f: + f.write("route_opt 999\n") + with open(os.path.join(self.baseline_casedocs, "mizuroute.toml"), "w") as f: + f.write("[route_opt]\nvalue = 5\n") + + match, comments = _do_full_nl_comp(self.caseroot, self.test_name, self.compare_name, self.baseline_root) + self.assertTrue(match) + + def test_baseline_has_only_toml(self): + """Verify comparison passes when baseline contains only migrated mizuroute.toml while CaseDocs contains both.""" + # Setup generated CaseDocs with both + with open(os.path.join(self.casedocs, "mizuRoute.control"), "w") as f: + f.write("route_opt 5\n") + with open(os.path.join(self.casedocs, "mizuroute.toml"), "w") as f: + f.write("[route_opt]\nvalue = 5\n") + + # Setup baseline with ONLY .toml + with open(os.path.join(self.baseline_casedocs, "mizuroute.toml"), "w") as f: + f.write("[route_opt]\nvalue = 5\n") + + match, comments = _do_full_nl_comp(self.caseroot, self.test_name, self.compare_name, self.baseline_root) + + # Baseline lacks mizuRoute.control, but transition logic in _do_full_nl_comp tolerates missing twin config file + self.assertTrue(match) + + def test_baseline_has_neither(self): + """Verify comparison fails when baseline contains neither mizuRoute.control nor mizuroute.toml.""" + # Setup generated CaseDocs with both + with open(os.path.join(self.casedocs, "mizuRoute.control"), "w") as f: + f.write("route_opt 5\n") + with open(os.path.join(self.casedocs, "mizuroute.toml"), "w") as f: + f.write("[route_opt]\nvalue = 5\n") + + # Baseline casedocs is empty (neither file exists) + match, comments = _do_full_nl_comp(self.caseroot, self.test_name, self.compare_name, self.baseline_root) + + self.assertFalse(match) + self.assertIn("Missing baseline namelist", comments) + + def test_casedocs_has_only_toml_baseline_has_control(self): + """Verify comparison fails when CaseDocs contains only mizuroute.toml but baseline contains only legacy mizuRoute.control.""" + # Setup generated CaseDocs with ONLY .toml + with open(os.path.join(self.casedocs, "mizuroute.toml"), "w") as f: + f.write("[route_opt]\nvalue = 5\n") + + # Setup baseline with ONLY .control + with open(os.path.join(self.baseline_casedocs, "mizuRoute.control"), "w") as f: + f.write("route_opt 5\n") + + match, comments = _do_full_nl_comp(self.caseroot, self.test_name, self.compare_name, self.baseline_root) + self.assertFalse(match) + self.assertIn("Missing baseline namelist", comments) + + def test_casedocs_has_only_control_baseline_has_toml(self): + """Verify comparison fails when CaseDocs contains only mizuRoute.control but baseline contains only migrated mizuroute.toml.""" + # Setup generated CaseDocs with ONLY .control + with open(os.path.join(self.casedocs, "mizuRoute.control"), "w") as f: + f.write("route_opt 5\n") + + # Setup baseline with ONLY .toml + with open(os.path.join(self.baseline_casedocs, "mizuroute.toml"), "w") as f: + f.write("[route_opt]\nvalue = 5\n") + + match, comments = _do_full_nl_comp(self.caseroot, self.test_name, self.compare_name, self.baseline_root) + self.assertFalse(match) + self.assertIn("Missing baseline namelist", comments) + +if __name__ == '__main__': + unittest.main() + + diff --git a/CIME/tests/test_unit_compare_namelists.py b/CIME/tests/test_unit_compare_namelists.py new file mode 100644 index 00000000000..75a27d21b20 --- /dev/null +++ b/CIME/tests/test_unit_compare_namelists.py @@ -0,0 +1,66 @@ +# Unit tests for CIME.compare_namelists TOML comparison functions. +import unittest +import tempfile +import os + +from CIME.compare_namelists import compare_namelist_files, is_namelist_file + +class TestCompareNamelists(unittest.TestCase): + def test_toml_whitespace_torture(self): + """Verify that TOML comparison ignores arbitrary whitespace and comments when matching key-value pairs.""" + gold_toml = """ +[route_opt] +value = 5 +# a comment +""" + compare_toml = """ + [ route_opt ] + value = 5 +""" + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".toml") as f1, \ + tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".toml") as f2: + f1.write(gold_toml) + f2.write(compare_toml) + f1_name = f1.name + f2_name = f2.name + + try: + self.assertTrue(is_namelist_file(f1_name)) + match, comments = compare_namelist_files(f1_name, f2_name) + self.assertTrue(match) + self.assertEqual(comments, "") + finally: + os.remove(f1_name) + os.remove(f2_name) + + def test_toml_compare_diff(self): + """Verify that TOML comparison correctly detects and reports mismatched key-value values.""" + gold_toml = """ +[route_opt] +value = 5 + +[physics] +method = "IRF" +""" + compare_toml = """ +[route_opt] +value = 5 + +[physics] +method = "MC" +""" + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".toml") as f1, \ + tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".toml") as f2: + f1.write(gold_toml) + f2.write(compare_toml) + f1_name = f1.name + f2_name = f2.name + + try: + match, comments = compare_namelist_files(f1_name, f2_name) + self.assertFalse(match) + self.assertIn("method had mismatched values", comments) + finally: + os.remove(f1_name) + os.remove(f2_name) + diff --git a/setup.py b/setup.py index ed77ad02286..2403120fd27 100644 --- a/setup.py +++ b/setup.py @@ -13,6 +13,7 @@ include_package_data=True, name="CIME", packages=find_packages(), + install_requires=["tomli; python_version < '3.11'"], test_suite="CIME.tests", tests_requires=["pytest"], url="https://github.com/ESMCI/cime", From 4ed19a18b81e24d61b80ac5f484feef8155c3a1b Mon Sep 17 00:00:00 2001 From: John Alex Date: Sat, 29 Aug 2026 15:35:25 -0400 Subject: [PATCH 2/2] feat(compare_namelists): Add support for _toml file diffing in compare_namelists. Initially intended for mizuroute toml files, but the code is generic. Note there was never explicit support for comparing legacy mizuroute control files, they just use the generic text differ if namelist parsing fails; so when the legacy control files go away, there will be nothing to change here. Overrides some of previous commit: * _do_full_nl_comp doesn't look for both toml and control anymore, just toml (since it's just reading merged results). Reverted corresponding test_unit_cmpgen_namelists_mizuroute.py file. * Looks for _toml not .toml --- CIME/case/case_cmpgen_namelists.py | 62 +++------ CIME/compare_namelists.py | 2 +- CIME/simple_compare.py | 44 +++++-- .../test_unit_cmpgen_namelists_mizuroute.py | 124 ------------------ CIME/tests/test_unit_compare_namelists.py | 29 +++- setup.py | 1 + 6 files changed, 73 insertions(+), 189 deletions(-) delete mode 100644 CIME/tests/test_unit_cmpgen_namelists_mizuroute.py diff --git a/CIME/case/case_cmpgen_namelists.py b/CIME/case/case_cmpgen_namelists.py index e505ca21d25..8bb527c24c9 100644 --- a/CIME/case/case_cmpgen_namelists.py +++ b/CIME/case/case_cmpgen_namelists.py @@ -16,16 +16,21 @@ logger = logging.getLogger(__name__) -def _do_full_nl_comp(caseroot, test, compare_name, baseline_root): - casedoc_dir = os.path.join(caseroot, "CaseDocs") +def _do_full_nl_comp(case, test, compare_name, baseline_root=None): + """Compare nearly all files between test case dir and baseline dir. + Special-case comparators for runconfig, namelists, and toml; the rest fall back to simple line-by-line diffing, see CIME/simple_compare.py. + If baseline_root is None, the root is extracted from the case BASELINE_ROOT setting. + """ + test_dir = case.get_value("CASEROOT") + casedoc_dir = os.path.join(test_dir, "CaseDocs") + baseline_root = ( + case.get_value("BASELINE_ROOT") if baseline_root is None else baseline_root + ) all_match = True - case_baseline_dir = os.path.join(baseline_root, compare_name, test) - baseline_casedocs = os.path.join(case_baseline_dir, "CaseDocs") + baseline_dir = os.path.join(baseline_root, compare_name, test) + baseline_casedocs = os.path.join(baseline_dir, "CaseDocs") - # Generic format migration transition check (e.g. .control <-> .toml). - # During format migration, if a case generates both formats (stem.control and stem.toml), - # allow comparing against whichever format exists in the baseline directory. # Start off by comparing everything in CaseDocs except a few arbitrary files (ugh!) # TODO: Namelist files should have consistent suffix all_items_to_compare = [ @@ -38,50 +43,14 @@ def _do_full_nl_comp(caseroot, test, compare_name, baseline_root): ] comments = "NLCOMP\n" - casedoc_files_lower = ( - {f.lower(): f for f in os.listdir(casedoc_dir)} - if os.path.exists(casedoc_dir) - else {} - ) - for item in all_items_to_compare: - filebase = os.path.basename(item) baseline_counterpart = os.path.join( baseline_casedocs if os.path.dirname(item).endswith("CaseDocs") - else case_baseline_dir, - filebase, + else baseline_dir, + os.path.basename(item), ) - baseline_dir = os.path.dirname(baseline_counterpart) - - # Generic format transition check (.control <-> .toml) - stem, ext = os.path.splitext(filebase) - ext_lower = ext.lower() - if ext_lower in [".control", ".toml"]: - alt_ext = ".toml" if ext_lower == ".control" else ".control" - alt_casedoc_key = (stem + alt_ext).lower() - has_both_in_casedocs = alt_casedoc_key in casedoc_files_lower - - if has_both_in_casedocs and os.path.exists(baseline_dir): - baseline_files_lower = { - f.lower(): f for f in os.listdir(baseline_dir) - } - alt_baseline_key = (stem + alt_ext).lower() - has_alt_in_baseline = alt_baseline_key in baseline_files_lower - - if ext_lower == ".control" and has_alt_in_baseline: - # Skip legacy .control if baseline has .toml (prefer .toml) - continue - if ( - ext_lower == ".toml" - and not os.path.exists(baseline_counterpart) - and has_alt_in_baseline - ): - # Skip .toml if baseline has legacy .control - continue - if not os.path.exists(baseline_counterpart): - comments += "Missing baseline namelist '{}'\n".format(baseline_counterpart) all_match = False else: @@ -200,9 +169,8 @@ def case_cmpgen_namelists( success = True output = "" if compare: - b_root = baseline_root if baseline_root is not None else self.get_value("BASELINE_ROOT") success, output = _do_full_nl_comp( - caseroot, test_name, compare_name, b_root + self, test_name, compare_name, baseline_root ) if not success and ts.get_status(RUN_PHASE) is not None: run_warn = """NOTE: It is not necessarily safe to compare namelists after RUN diff --git a/CIME/compare_namelists.py b/CIME/compare_namelists.py index 41b5aa18976..96ceafc4207 100644 --- a/CIME/compare_namelists.py +++ b/CIME/compare_namelists.py @@ -842,7 +842,7 @@ def compare_namelist_files(gold_file, compare_file, case=None): if gold_file.endswith(".yaml") or gold_file.endswith(".yml"): comments = _compare_yamls(gold_file, compare_file, case) - elif gold_file.endswith(".toml"): + elif gold_file.endswith("_toml"): comments = _compare_tomls(gold_file, compare_file, case) else: gold_namelists = _parse_namelists(open(gold_file, "r").readlines(), gold_file) diff --git a/CIME/simple_compare.py b/CIME/simple_compare.py index f1292778157..82f63a42176 100644 --- a/CIME/simple_compare.py +++ b/CIME/simple_compare.py @@ -59,9 +59,29 @@ def _skip_comments_and_whitespace(lines, idx): ############################################################################### -def _compare_data(gold_lines, comp_lines, case, offset_method=False): +def compare_lines_generic(gold_lines, comp_lines, case_id, try_realign=False): ############################################################################### """ + Compare lines in order, filtering #! comment lines and leading/trailing whitepace. + + Expected Input Format: + gold_lines (list[str]): Lines of text from the gold/baseline file (e.g. readlines()). + comp_lines (list[str]): Lines of text from the file being compared against gold. + case_id (str | None): Optional case base ID string used to normalize case IDs/timestamps. + try_realign (bool): If True, advances the index of the longer line list on mismatch + to attempt realigning shifted lines. Default is False. + + Returns: + tuple[str, int]: A tuple of (diff_desc, mismatch_count), where diff_desc contains diff + output describing inequivalent, extra, or missing lines, and + mismatch_count is the integer count of line mismatches. + + Note: + This comparison only strips leading and trailing whitespace per line (.strip()) and skips + lines that start with '#' or '!'. It is NOT fully whitespace-agnostic: internal spacing + differences (e.g., between key and value), inline comments at line ends, and line-order + changes will trigger mismatch errors. + >>> teststr = ''' ... data1 ... data2 data3 @@ -70,7 +90,7 @@ def _compare_data(gold_lines, comp_lines, case, offset_method=False): ... # Comment ... data7 data8 data9 data10 ... ''' - >>> _compare_data(teststr.splitlines(), teststr.splitlines(), None) + >>> compare_lines_generic(teststr.splitlines(), teststr.splitlines(), None) ('', 0) >>> teststr2 = ''' @@ -80,7 +100,7 @@ def _compare_data(gold_lines, comp_lines, case, offset_method=False): ... data7 data8 data9 data10 ... data00 ... ''' - >>> results,_ = _compare_data(teststr.splitlines(), teststr2.splitlines(), None) + >>> results,_ = compare_lines_generic(teststr.splitlines(), teststr2.splitlines(), None) >>> print(results) Inequivalent lines data2 data3 != data2 data30 NORMALIZED: data2 data3 != data2 data30 @@ -93,7 +113,7 @@ def _compare_data(gold_lines, comp_lines, case, offset_method=False): ... data7 data8 data9 data10 ... data00 ... ''' - >>> results,_ = _compare_data(teststr3.splitlines(), teststr2.splitlines(), None, offset_method=True) + >>> results,_ = compare_lines_generic(teststr3.splitlines(), teststr2.splitlines(), None, try_realign=True) >>> print(results) Inequivalent lines data4 data5 data6 != data2 data30 NORMALIZED: data4 data5 data6 != data2 data30 @@ -124,15 +144,15 @@ def _compare_data(gold_lines, comp_lines, case, offset_method=False): comp_value = comp_lines[cidx].strip() comp_value = comp_value.replace('"', "'") - norm_gold_value = _normalize_string_value(gold_value, case) - norm_comp_value = _normalize_string_value(comp_value, case) + norm_gold_value = _normalize_string_value(gold_value, case_id) + norm_comp_value = _normalize_string_value(comp_value, case_id) if norm_gold_value != norm_comp_value: comments += "Inequivalent lines {} != {}\n".format(gold_value, comp_value) comments += " NORMALIZED: {} != {}\n".format( norm_gold_value, norm_comp_value ) cnt += 1 - if offset_method and (norm_gold_value != norm_comp_value): + if try_realign and (norm_gold_value != norm_comp_value): if gnum > cnum: gidx += 1 else: @@ -148,22 +168,22 @@ def _compare_data(gold_lines, comp_lines, case, offset_method=False): def compare_files(gold_file, compare_file, case=None): ############################################################################### """ - Returns true if files are the same, comments are returned too: - (success, comments) + Compare two text files with compare_lines_generic algorithm. """ expect(os.path.exists(gold_file), "File not found: {}".format(gold_file)) expect(os.path.exists(compare_file), "File not found: {}".format(compare_file)) - comments, cnt = _compare_data( + comments, cnt = compare_lines_generic( open(gold_file, "r").readlines(), open(compare_file, "r").readlines(), case ) + # If initial comparison finds mismatches, retry with offset realignment to see if line shifting yields fewer errors. if cnt > 0: - comments2, cnt2 = _compare_data( + comments2, cnt2 = compare_lines_generic( open(gold_file, "r").readlines(), open(compare_file, "r").readlines(), case, - offset_method=True, + try_realign=True, ) if cnt2 < cnt: comments = comments2 diff --git a/CIME/tests/test_unit_cmpgen_namelists_mizuroute.py b/CIME/tests/test_unit_cmpgen_namelists_mizuroute.py deleted file mode 100644 index e5492456e4e..00000000000 --- a/CIME/tests/test_unit_cmpgen_namelists_mizuroute.py +++ /dev/null @@ -1,124 +0,0 @@ -# Unit tests for mizuRoute baseline namelist comparison transition logic. -import unittest -import tempfile -import os -import shutil - -from CIME.case.case_cmpgen_namelists import _do_full_nl_comp - -class TestMizuRouteNamelistCompare(unittest.TestCase): - def setUp(self): - self.tempdir = tempfile.mkdtemp() - self.caseroot = os.path.join(self.tempdir, "caseroot") - self.casedocs = os.path.join(self.caseroot, "CaseDocs") - self.baseline_root = os.path.join(self.tempdir, "baselines") - - self.compare_name = "test_cmp" - self.test_name = "test_case" - - self.baseline_dir = os.path.join(self.baseline_root, self.compare_name, self.test_name) - self.baseline_casedocs = os.path.join(self.baseline_dir, "CaseDocs") - - os.makedirs(self.casedocs) - os.makedirs(self.baseline_casedocs) - - def tearDown(self): - shutil.rmtree(self.tempdir, ignore_errors=True) - - def test_baseline_has_only_control(self): - """Verify comparison passes when baseline contains only legacy mizuRoute.control file while CaseDocs contains both.""" - # Setup generated CaseDocs with both - with open(os.path.join(self.casedocs, "mizuRoute.control"), "w") as f: - f.write("route_opt 5\n") - with open(os.path.join(self.casedocs, "mizuroute.toml"), "w") as f: - f.write("[route_opt]\nvalue = 5\n") - - # Setup baseline with ONLY .control - with open(os.path.join(self.baseline_casedocs, "mizuRoute.control"), "w") as f: - f.write("route_opt 5\n") - - match, comments = _do_full_nl_comp(self.caseroot, self.test_name, self.compare_name, self.baseline_root) - - # Baseline lacks mizuroute.toml, but transition logic in _do_full_nl_comp tolerates missing twin config file - self.assertTrue(match) - - def test_baseline_has_both(self): - """Verify comparison prefers mizuroute.toml and ignores mizuRoute.control when both exist in baseline.""" - # Setup generated CaseDocs with both - with open(os.path.join(self.casedocs, "mizuRoute.control"), "w") as f: - f.write("route_opt 5\n") - with open(os.path.join(self.casedocs, "mizuroute.toml"), "w") as f: - f.write("[route_opt]\nvalue = 5\n") - - # Setup baseline with BOTH, but with legacy .control having mismatching content - with open(os.path.join(self.baseline_casedocs, "mizuRoute.control"), "w") as f: - f.write("route_opt 999\n") - with open(os.path.join(self.baseline_casedocs, "mizuroute.toml"), "w") as f: - f.write("[route_opt]\nvalue = 5\n") - - match, comments = _do_full_nl_comp(self.caseroot, self.test_name, self.compare_name, self.baseline_root) - self.assertTrue(match) - - def test_baseline_has_only_toml(self): - """Verify comparison passes when baseline contains only migrated mizuroute.toml while CaseDocs contains both.""" - # Setup generated CaseDocs with both - with open(os.path.join(self.casedocs, "mizuRoute.control"), "w") as f: - f.write("route_opt 5\n") - with open(os.path.join(self.casedocs, "mizuroute.toml"), "w") as f: - f.write("[route_opt]\nvalue = 5\n") - - # Setup baseline with ONLY .toml - with open(os.path.join(self.baseline_casedocs, "mizuroute.toml"), "w") as f: - f.write("[route_opt]\nvalue = 5\n") - - match, comments = _do_full_nl_comp(self.caseroot, self.test_name, self.compare_name, self.baseline_root) - - # Baseline lacks mizuRoute.control, but transition logic in _do_full_nl_comp tolerates missing twin config file - self.assertTrue(match) - - def test_baseline_has_neither(self): - """Verify comparison fails when baseline contains neither mizuRoute.control nor mizuroute.toml.""" - # Setup generated CaseDocs with both - with open(os.path.join(self.casedocs, "mizuRoute.control"), "w") as f: - f.write("route_opt 5\n") - with open(os.path.join(self.casedocs, "mizuroute.toml"), "w") as f: - f.write("[route_opt]\nvalue = 5\n") - - # Baseline casedocs is empty (neither file exists) - match, comments = _do_full_nl_comp(self.caseroot, self.test_name, self.compare_name, self.baseline_root) - - self.assertFalse(match) - self.assertIn("Missing baseline namelist", comments) - - def test_casedocs_has_only_toml_baseline_has_control(self): - """Verify comparison fails when CaseDocs contains only mizuroute.toml but baseline contains only legacy mizuRoute.control.""" - # Setup generated CaseDocs with ONLY .toml - with open(os.path.join(self.casedocs, "mizuroute.toml"), "w") as f: - f.write("[route_opt]\nvalue = 5\n") - - # Setup baseline with ONLY .control - with open(os.path.join(self.baseline_casedocs, "mizuRoute.control"), "w") as f: - f.write("route_opt 5\n") - - match, comments = _do_full_nl_comp(self.caseroot, self.test_name, self.compare_name, self.baseline_root) - self.assertFalse(match) - self.assertIn("Missing baseline namelist", comments) - - def test_casedocs_has_only_control_baseline_has_toml(self): - """Verify comparison fails when CaseDocs contains only mizuRoute.control but baseline contains only migrated mizuroute.toml.""" - # Setup generated CaseDocs with ONLY .control - with open(os.path.join(self.casedocs, "mizuRoute.control"), "w") as f: - f.write("route_opt 5\n") - - # Setup baseline with ONLY .toml - with open(os.path.join(self.baseline_casedocs, "mizuroute.toml"), "w") as f: - f.write("[route_opt]\nvalue = 5\n") - - match, comments = _do_full_nl_comp(self.caseroot, self.test_name, self.compare_name, self.baseline_root) - self.assertFalse(match) - self.assertIn("Missing baseline namelist", comments) - -if __name__ == '__main__': - unittest.main() - - diff --git a/CIME/tests/test_unit_compare_namelists.py b/CIME/tests/test_unit_compare_namelists.py index 75a27d21b20..7829f874eeb 100644 --- a/CIME/tests/test_unit_compare_namelists.py +++ b/CIME/tests/test_unit_compare_namelists.py @@ -6,7 +6,7 @@ from CIME.compare_namelists import compare_namelist_files, is_namelist_file class TestCompareNamelists(unittest.TestCase): - def test_toml_whitespace_torture(self): + def test_toml_whitespace_handling(self): """Verify that TOML comparison ignores arbitrary whitespace and comments when matching key-value pairs.""" gold_toml = """ [route_opt] @@ -17,8 +17,8 @@ def test_toml_whitespace_torture(self): [ route_opt ] value = 5 """ - with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".toml") as f1, \ - tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".toml") as f2: + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix="_toml") as f1, \ + tempfile.NamedTemporaryFile(mode="w", delete=False, suffix="_toml") as f2: f1.write(gold_toml) f2.write(compare_toml) f1_name = f1.name @@ -49,8 +49,8 @@ def test_toml_compare_diff(self): [physics] method = "MC" """ - with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".toml") as f1, \ - tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".toml") as f2: + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix="_toml") as f1, \ + tempfile.NamedTemporaryFile(mode="w", delete=False, suffix="_toml") as f2: f1.write(gold_toml) f2.write(compare_toml) f1_name = f1.name @@ -64,3 +64,22 @@ def test_toml_compare_diff(self): os.remove(f1_name) os.remove(f2_name) + def test_underscore_toml_extension(self): + """Verify TOML files ending in _toml (such as mizuroute_toml) are correctly parsed as TOML namelists.""" + content = "[route_opt]\nvalue = 5\n" + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix="_toml") as f1, \ + tempfile.NamedTemporaryFile(mode="w", delete=False, suffix="_toml") as f2: + f1.write(content) + f2.write(content) + f1_name = f1.name + f2_name = f2.name + + try: + self.assertTrue(is_namelist_file(f1_name)) + match, comments = compare_namelist_files(f1_name, f2_name) + self.assertTrue(match) + finally: + os.remove(f1_name) + os.remove(f2_name) + + diff --git a/setup.py b/setup.py index 2403120fd27..d93f67f9f01 100644 --- a/setup.py +++ b/setup.py @@ -13,6 +13,7 @@ include_package_data=True, name="CIME", packages=find_packages(), + # Python 3.10 will end-of-life in Oct 2026; tomli is required for python_version < '3.11' install_requires=["tomli; python_version < '3.11'"], test_suite="CIME.tests", tests_requires=["pytest"],