diff --git a/CIME/SystemTests/test_mods.py b/CIME/SystemTests/test_mods.py index 2db22c5833a..d7f010f1d86 100644 --- a/CIME/SystemTests/test_mods.py +++ b/CIME/SystemTests/test_mods.py @@ -1,7 +1,7 @@ import logging import os -from CIME.utils import CIMEError +from CIME.core.exceptions import CIMEError from CIME.XML.files import Files logger = logging.getLogger(__name__) diff --git a/CIME/XML/compsets.py b/CIME/XML/compsets.py index eef9253da0f..70241ec2e98 100644 --- a/CIME/XML/compsets.py +++ b/CIME/XML/compsets.py @@ -6,7 +6,7 @@ from CIME.XML.generic_xml import GenericXML from CIME.XML.entry_id import EntryID from CIME.XML.files import Files -from CIME.utils import CIMEError +from CIME.core.exceptions import CIMEError logger = logging.getLogger(__name__) diff --git a/CIME/XML/machines.py b/CIME/XML/machines.py index c40e73ba3b6..eabd6a8f9ef 100644 --- a/CIME/XML/machines.py +++ b/CIME/XML/machines.py @@ -5,7 +5,8 @@ from CIME.XML.standard_module_setup import * from CIME.XML.generic_xml import GenericXML from CIME.XML.files import Files -from CIME.utils import CIMEError, expect, convert_to_unknown_type, get_cime_config +from CIME.core.exceptions import CIMEError +from CIME.utils import expect, convert_to_unknown_type, get_cime_config import re import logging diff --git a/CIME/XML/namelist_definition.py b/CIME/XML/namelist_definition.py index 25f7c0e0432..38a184d1019 100644 --- a/CIME/XML/namelist_definition.py +++ b/CIME/XML/namelist_definition.py @@ -21,7 +21,7 @@ Namelist, get_fortran_name_only, ) -from CIME.utils import CIMEError +from CIME.core.exceptions import CIMEError from CIME.XML.standard_module_setup import * from CIME.XML.entry_id import EntryID from CIME.XML.files import Files @@ -32,7 +32,6 @@ class CaseInsensitiveDict(dict): - """Basic case insensitive dict with strings only keys. From https://stackoverflow.com/a/27890005""" @@ -65,7 +64,6 @@ def __setitem__(self, k, v): class NamelistDefinition(EntryID): - """Class representing variable definitions for a namelist. This class inherits from `EntryID`, and supports most inherited methods; however, `set_value` is unsupported. diff --git a/CIME/XML/tests.py b/CIME/XML/tests.py index e760d86604f..92feaca91c8 100644 --- a/CIME/XML/tests.py +++ b/CIME/XML/tests.py @@ -1,11 +1,13 @@ """ Interface to the config_tests.xml file. This class inherits from GenericEntry """ + from CIME.XML.standard_module_setup import * from CIME.XML.generic_xml import GenericXML from CIME.XML.files import Files -from CIME.utils import find_system_test, CIMEError +from CIME.core.exceptions import CIMEError +from CIME.utils import find_system_test from CIME.SystemTests.system_tests_compare_two import SystemTestsCompareTwo from CIME.SystemTests.system_tests_compare_n import SystemTestsCompareN diff --git a/CIME/case/case_run.py b/CIME/case/case_run.py index b9912589e5f..5c44c693b14 100644 --- a/CIME/case/case_run.py +++ b/CIME/case/case_run.py @@ -5,7 +5,8 @@ from CIME.XML.standard_module_setup import * from CIME.config import Config from CIME.utils import gzip_existing_file, new_lid -from CIME.utils import run_sub_or_cmd, safe_copy, model_log, CIMEError +from CIME.core.exceptions import CIMEError +from CIME.utils import run_sub_or_cmd, safe_copy, model_log from CIME.utils import batch_jobid, is_comp_standalone from CIME.status import append_status, run_and_log_case_status from CIME.get_timing import get_timing diff --git a/CIME/case/case_submit.py b/CIME/case/case_submit.py index 9251e4ab092..6e2e7bc6278 100644 --- a/CIME/case/case_submit.py +++ b/CIME/case/case_submit.py @@ -6,9 +6,11 @@ jobs. submit, check_case and check_da_settings are members of class Case in file case.py """ + import configparser from CIME.XML.standard_module_setup import * -from CIME.utils import expect, CIMEError, get_time_in_seconds +from CIME.core.exceptions import CIMEError +from CIME.utils import expect, get_time_in_seconds from CIME.status import run_and_log_case_status from CIME.locked_files import ( unlock_file, diff --git a/CIME/compare_namelists.py b/CIME/compare_namelists.py index 9fdc005e3cc..6e98a7df9a5 100644 --- a/CIME/compare_namelists.py +++ b/CIME/compare_namelists.py @@ -1,10 +1,12 @@ import os, re, logging -from CIME.utils import expect, CIMEError +from CIME.core.exceptions import CIMEError +from CIME.utils import expect logger = logging.getLogger(__name__) # pragma pylint: disable=unsubscriptable-object + ############################################################################### def _normalize_lists(value_str): ############################################################################### diff --git a/CIME/core/__init__.py b/CIME/core/__init__.py new file mode 100644 index 00000000000..37879087e2e --- /dev/null +++ b/CIME/core/__init__.py @@ -0,0 +1,7 @@ +""" +CIME core package — Dependency Injection (DI) based abstractions for filesystem, process, environment, +clock, and configuration bootstrap. + +These protocols are designed for constructor injection so that business logic +can be tested with lightweight mocks instead of hitting real OS resources. +""" diff --git a/CIME/core/config/__init__.py b/CIME/core/config/__init__.py new file mode 100644 index 00000000000..ddadce17d5b --- /dev/null +++ b/CIME/core/config/__init__.py @@ -0,0 +1 @@ +"""Configuration bootstrap and loading utilities.""" diff --git a/CIME/core/config/bootstrap.py b/CIME/core/config/bootstrap.py new file mode 100644 index 00000000000..7e66cfe5928 --- /dev/null +++ b/CIME/core/config/bootstrap.py @@ -0,0 +1,182 @@ +""" +Centralized sys.path management for CIME. + +CIME is not pip-installed; it relies on sys.path manipulation so that +scripts executed via symlinks (e.g. case.setup, xmlchange) can find +the CIME package. This module consolidates the various sys.path.insert +patterns scattered across the codebase into one place. + +**This module is standalone for Slice 1** — existing call-sites are NOT +modified yet. Migration will happen incrementally in later slices. + +Typical usage (future, after wiring):: + + from CIME.core.config.bootstrap import bootstrap_cime + bootstrap_cime() # auto-detect CIMEROOT + bootstrap_cime(cimeroot="/explicit") # explicit root +""" + +import os +import sys +import warnings +from typing import List, Optional, Sequence + + +def find_cimeroot(starting_dir: Optional[str] = None) -> str: + """Locate the CIME root directory. + + Resolution order: + 1. ``starting_dir`` argument (if provided) + 2. ``CIMEROOT`` environment variable + 3. Walk upward from this file to find the directory containing ``CIME/`` + + Returns: + Absolute path to the CIME root directory. + + Raises: + RuntimeError: If CIMEROOT cannot be determined. + """ + if starting_dir is not None: + resolved = os.path.abspath(starting_dir) + if _is_cimeroot(resolved): + return resolved + raise RuntimeError(f"Specified directory is not a valid CIMEROOT: {resolved}") + + env_root = os.environ.get("CIMEROOT") + if env_root and _is_cimeroot(env_root): + return os.path.abspath(env_root) + + # Walk up from this file: CIME/core/config/bootstrap.py -> CIME/core/config -> CIME/core -> CIME -> root + candidate = os.path.abspath( + os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "..", "..") + ) + if _is_cimeroot(candidate): + return candidate + + raise RuntimeError( + "Cannot determine CIMEROOT. Set the CIMEROOT environment variable " + "or pass cimeroot explicitly." + ) + + +def _is_cimeroot(path: str) -> bool: + """Check whether a directory looks like a valid CIMEROOT.""" + return os.path.isdir(os.path.join(path, "CIME")) + + +def bootstrap_cime( + cimeroot: Optional[str] = None, + extra_paths: Optional[Sequence[str]] = None, + set_env: bool = True, +) -> str: + """Set up sys.path for CIME and return the resolved CIMEROOT. + + This is the single function that should be called to prepare the + Python environment for CIME imports. It: + + 1. Resolves CIMEROOT + 2. Inserts CIMEROOT at the front of sys.path (if not already present) + 3. Inserts CIME/Tools at position 1 (if not already present) + 4. Inserts any extra_paths + 5. Optionally sets the CIMEROOT environment variable + + Args: + cimeroot: Explicit CIMEROOT path. If None, auto-detected. + extra_paths: Additional paths to insert after CIMEROOT and Tools. + set_env: Whether to set ``os.environ["CIMEROOT"]``. + + Returns: + The resolved CIMEROOT path. + """ + root = find_cimeroot(cimeroot) + tools_path = get_tools_path(root) + + paths_to_add: List[str] = [root, tools_path] + if extra_paths: + paths_to_add.extend(str(p) for p in extra_paths) + + _prepend_sys_path(paths_to_add) + + if set_env: + os.environ["CIMEROOT"] = root + + return root + + +def _prepend_sys_path(paths: Sequence[str]) -> None: + """Prepend paths to the front of ``sys.path``, order-preserving and deduped. + + Each path is converted to an absolute path and placed at the front of + ``sys.path`` such that ``paths[0]`` ends up at index 0, ``paths[1]`` at + index 1, and so on. If a path is already present it is removed first, so + the entry is repositioned to the front rather than duplicated. + + Duplicate entries within ``paths`` itself are collapsed to the first + occurrence before any insertion, so positions are stable regardless of + how many times a path appears in the input. A ``UserWarning`` is issued + when duplicates are detected, since they may indicate a caller bug that + would silently affect import precedence. + + Args: + paths: Paths to place at the front of ``sys.path``, highest priority + first. + + Warns: + UserWarning: If ``paths`` contains duplicate entries. + + Example: + Given ``sys.path = [A, B, C]`` and ``paths = [C, D, C]``, the + duplicate ``C`` is collapsed to its first occurrence, giving an + effective prepend list of ``[C, D]``. The result is:: + + sys.path == [C, D, A, B] + """ + # Deduplicate while preserving first-occurrence order. + abs_paths = [os.path.abspath(p) for p in paths] + unique = list(dict.fromkeys(abs_paths)) + duplicates = [p for p in unique if abs_paths.count(p) > 1] + if duplicates: + warnings.warn( + f"Duplicate paths passed to _prepend_sys_path: {duplicates}. " + "First occurrence takes precedence; this may affect import order.", + UserWarning, + stacklevel=2, + ) + for i, absp in enumerate(unique): + # Remove any existing entry so the path is repositioned, not duplicated. + if absp in sys.path: + sys.path.remove(absp) + sys.path.insert(i, absp) + + +def get_tools_path(cimeroot: Optional[str] = None) -> str: + """Return the CIME/Tools directory path.""" + root = cimeroot or find_cimeroot() + return os.path.join(root, "CIME", "Tools") + + +def check_minimum_python_version( + major: int = 3, minor: int = 9, warn_only: bool = False +) -> None: + """Check that the running Python meets minimum version requirements. + + Consolidated from CIME/Tools/standard_script_setup.py. + + Args: + major: Required major version. + minor: Required minimum minor version. + warn_only: If True, print warning instead of raising. + + Raises: + RuntimeError: If version is insufficient and warn_only is False. + """ + if sys.version_info >= (major, minor): + return + msg = ( + f"Python {major}.{minor} is {'recommended' if warn_only else 'required'} " + f"to run CIME. You have {sys.version_info[0]}.{sys.version_info[1]}" + ) + if warn_only: + print(msg + ".", file=sys.stderr) + return + raise RuntimeError(msg + " - please use a newer version of Python.") diff --git a/CIME/core/exceptions.py b/CIME/core/exceptions.py new file mode 100644 index 00000000000..c8f69c59e4f --- /dev/null +++ b/CIME/core/exceptions.py @@ -0,0 +1,81 @@ +""" +Typed exception hierarchy for CIME. + +CIMEError inherits from both SystemExit and Exception so that: +- Tracebacks are suppressed in normal usage (SystemExit behavior) +- Exceptions are still catchable (Exception behavior) +- Users can run with --debug to see full tracebacks + +All CIME-specific exceptions should inherit from CIMEError. +""" + + +class CIMEError(SystemExit, Exception): + """Base exception for all CIME errors. + + Inherits from SystemExit to suppress tracebacks in normal usage, + and from Exception to remain catchable. + """ + + +class ConfigurationError(CIMEError): + """Invalid or missing configuration (XML files, env vars, etc.).""" + + +class BuildError(CIMEError): + """Failure during model build.""" + + +class SubmitError(CIMEError): + """Failure during job submission.""" + + +class SystemTestError(CIMEError): + """Failure in a CIME system test.""" + + +class LockingError(CIMEError): + """Case locking or unlocking failure.""" + + +class ExternalCommandError(CIMEError): + """An external command (subprocess) failed. + + Modeled after subprocess.CalledProcessError for consistency. + + Attributes: + returncode: Exit code of the command. + cmd: The command that was run (string or list). + output: Standard output captured from the command, if any. + stderr: Standard error captured from the command, if any. + """ + + def __init__( + self, + message: str, + returncode: int = 1, + cmd: str = "", + output: str = "", + stderr: str = "", + # Deprecated: use cmd instead + command: str = "", + ): + super().__init__(message) + self.returncode = returncode + # Support both 'cmd' (preferred) and 'command' (deprecated) for compatibility + self.cmd = cmd or command + self.output = output + self.stderr = stderr + + @property + def command(self) -> str: + """Deprecated: Use cmd instead.""" + return self.cmd + + +class RetryableExternalCommandError(ExternalCommandError): + """An external command failed but may succeed on retry (transient error).""" + + +class InputError(CIMEError): + """Invalid user input (bad arguments, missing required values).""" diff --git a/CIME/get_tests.py b/CIME/get_tests.py index a4d4abb3410..4b1621dc3af 100644 --- a/CIME/get_tests.py +++ b/CIME/get_tests.py @@ -356,7 +356,7 @@ def infer_arch_from_tests(testargs): ('melvin', ['gnu']) >>> infer_arch_from_tests(["NCK.f19_g16.A.melvin_gnu9", "NCK.f19_g16.A.melvin_gnu"]) ('melvin', ['gnu9', 'gnu']) - >>> infer_arch_from_tests(["NCK.f19_g16.A.melvin_gnu", "NCK.f19_g16.A.mappy_gnu"]) + >>> infer_arch_from_tests(["NCK.f19_g16.A.melvin_gnu", "NCK.f19_g16.A.mappy_gnu"]) # doctest: +IGNORE_EXCEPTION_DETAIL Traceback (most recent call last): ... CIME.utils.CIMEError: ERROR: Must have consistent machine 'melvin' != 'mappy' diff --git a/CIME/hist_utils.py b/CIME/hist_utils.py index fa48f942c56..ab8b387e853 100644 --- a/CIME/hist_utils.py +++ b/CIME/hist_utils.py @@ -1,6 +1,7 @@ """ Functions for actions pertaining to history files. """ + import logging import os import re @@ -17,7 +18,7 @@ SharedArea, parse_test_name, ) -from CIME.utils import CIMEError +from CIME.core.exceptions import CIMEError logger = logging.getLogger(__name__) diff --git a/CIME/tests/test_unit_bless_test_results.py b/CIME/tests/test_unit_bless_test_results.py index 3138dd75c77..058c10caab8 100644 --- a/CIME/tests/test_unit_bless_test_results.py +++ b/CIME/tests/test_unit_bless_test_results.py @@ -15,7 +15,7 @@ ) from CIME.test_status import ALL_PHASES, GENERATE_PHASE from CIME.tests import utils as test_utils -from CIME.utils import CIMEError +from CIME.core.exceptions import CIMEError class TestUnitBlessTestResults(unittest.TestCase): diff --git a/CIME/tests/test_unit_case_run.py b/CIME/tests/test_unit_case_run.py index 88c746d0d8c..ce74f675366 100644 --- a/CIME/tests/test_unit_case_run.py +++ b/CIME/tests/test_unit_case_run.py @@ -1,7 +1,7 @@ import unittest from unittest import mock -from CIME.utils import CIMEError +from CIME.core.exceptions import CIMEError from CIME.case.case_run import TERMINATION_TEXT from CIME.case.case_run import _post_run_check diff --git a/CIME/tests/test_unit_core_bootstrap.py b/CIME/tests/test_unit_core_bootstrap.py new file mode 100644 index 00000000000..8c8dc735c99 --- /dev/null +++ b/CIME/tests/test_unit_core_bootstrap.py @@ -0,0 +1,191 @@ +"""Unit tests for CIME.core.config.bootstrap.""" + +import os +import sys + +import pytest + +from CIME.core.config.bootstrap import ( + _prepend_sys_path, + _is_cimeroot, + bootstrap_cime, + check_minimum_python_version, + find_cimeroot, + get_tools_path, +) + + +class TestFindCimeroot: + def test_explicit_dir(self, tmp_path): + """find_cimeroot accepts an explicit directory with a CIME/ subdir.""" + (tmp_path / "CIME").mkdir() + root = find_cimeroot(str(tmp_path)) + assert root == str(tmp_path.resolve()) + + def test_explicit_dir_invalid(self, tmp_path): + with pytest.raises(RuntimeError, match="not a valid CIMEROOT"): + find_cimeroot(str(tmp_path)) + + def test_env_var(self, tmp_path, monkeypatch): + (tmp_path / "CIME").mkdir() + monkeypatch.setenv("CIMEROOT", str(tmp_path)) + root = find_cimeroot() + assert root == str(tmp_path.resolve()) + + def test_walks_up_from_file(self): + """The default detection should find the real CIMEROOT from this repo.""" + root = find_cimeroot() + assert os.path.isdir(os.path.join(root, "CIME")) + + +class TestIsCimeroot: + def test_valid(self, tmp_path): + (tmp_path / "CIME").mkdir() + assert _is_cimeroot(str(tmp_path)) is True + + def test_invalid(self, tmp_path): + assert _is_cimeroot(str(tmp_path)) is False + + +class TestBootstrapCime: + def setup_method(self): + self._orig_path = sys.path[:] + self._orig_env = os.environ.get("CIMEROOT") + + def teardown_method(self): + sys.path[:] = self._orig_path + if self._orig_env is not None: + os.environ["CIMEROOT"] = self._orig_env + else: + os.environ.pop("CIMEROOT", None) + + def test_bootstrap_sets_sys_path(self, tmp_path): + (tmp_path / "CIME" / "Tools").mkdir(parents=True) + root = bootstrap_cime(cimeroot=str(tmp_path)) + assert root == str(tmp_path.resolve()) + assert str(tmp_path.resolve()) in sys.path + tools = os.path.join(str(tmp_path.resolve()), "CIME", "Tools") + assert tools in sys.path + + def test_bootstrap_sets_env(self, tmp_path): + (tmp_path / "CIME" / "Tools").mkdir(parents=True) + bootstrap_cime(cimeroot=str(tmp_path)) + assert os.environ["CIMEROOT"] == str(tmp_path.resolve()) + + def test_bootstrap_no_env(self, tmp_path): + (tmp_path / "CIME" / "Tools").mkdir(parents=True) + os.environ.pop("CIMEROOT", None) + bootstrap_cime(cimeroot=str(tmp_path), set_env=False) + assert "CIMEROOT" not in os.environ + + def test_extra_paths(self, tmp_path): + (tmp_path / "CIME" / "Tools").mkdir(parents=True) + extra = tmp_path / "extras" + extra.mkdir() + bootstrap_cime(cimeroot=str(tmp_path), extra_paths=[str(extra)]) + assert str(extra.resolve()) in sys.path + + def test_no_duplicate_paths(self, tmp_path): + (tmp_path / "CIME" / "Tools").mkdir(parents=True) + bootstrap_cime(cimeroot=str(tmp_path)) + bootstrap_cime(cimeroot=str(tmp_path)) + root_str = str(tmp_path.resolve()) + assert sys.path.count(root_str) == 1 + + +class TestPrependSysPath: + def setup_method(self): + self._orig_path = sys.path[:] + + def teardown_method(self): + sys.path[:] = self._orig_path + + def test_inserts_in_order(self, tmp_path): + a = str(tmp_path / "a") + b = str(tmp_path / "b") + + # Context + existing = [p for p in sys.path if p not in (a, b)] + + # Act + _prepend_sys_path([a, b]) + + # Assert + assert sys.path[0] == a + assert sys.path[1] == b + for p in existing: + assert p in sys.path + + def test_moves_existing_entry(self, tmp_path): + p = str(tmp_path / "x") + + # Context + sys.path.append(p) + existing = [x for x in sys.path if x != p] + + # Act + _prepend_sys_path([p]) + + # Assert + assert sys.path[0] == p + assert sys.path.count(p) == 1 + for x in existing: + assert x in sys.path + + def test_duplicate_in_input_uses_first_occurrence_position(self, tmp_path): + # sys.path = [A, B, C]; paths = [C, D, C] + # C appears twice in paths; first-occurrence wins so the effective + # list is [C, D]. C must land at index 0, D at index 1. + A = str(tmp_path / "A") + B = str(tmp_path / "B") + C = str(tmp_path / "C") + D = str(tmp_path / "D") + + # Context + sys.path[:] = [A, B, C] + + # Act + _prepend_sys_path([C, D, C]) + + # Assert + assert sys.path[0] == C + assert sys.path[1] == D + assert sys.path.count(C) == 1 + assert sys.path.count(D) == 1 + assert A in sys.path + assert B in sys.path + + def test_duplicate_in_input_warns(self, tmp_path): + # Duplicate paths in the input list should raise a UserWarning so + # callers are alerted that import precedence may be affected. + C = str(tmp_path / "C") + D = str(tmp_path / "D") + + # Context + sys.path[:] = [] + + # Act / Assert + with pytest.warns(UserWarning, match="Duplicate paths"): + _prepend_sys_path([C, D, C]) + + +class TestGetToolsPath: + def test_returns_tools_dir(self, tmp_path): + (tmp_path / "CIME" / "Tools").mkdir(parents=True) + tools = get_tools_path(cimeroot=str(tmp_path)) + assert tools == os.path.join(str(tmp_path), "CIME", "Tools") + + +class TestCheckMinimumPythonVersion: + def test_passes_current_version(self): + # Current Python is >= 3.9 (required by CIME) + check_minimum_python_version(3, 9) + + def test_fails_future_version(self): + with pytest.raises(RuntimeError, match="required"): + check_minimum_python_version(99, 0) + + def test_warn_only(self, capsys): + check_minimum_python_version(99, 0, warn_only=True) + captured = capsys.readouterr() + assert "recommended" in captured.err diff --git a/CIME/tests/test_unit_core_exceptions.py b/CIME/tests/test_unit_core_exceptions.py new file mode 100644 index 00000000000..79c5907d812 --- /dev/null +++ b/CIME/tests/test_unit_core_exceptions.py @@ -0,0 +1,124 @@ +"""Unit tests for CIME.core.exceptions.""" + +import pytest + +from CIME.core.exceptions import ( + BuildError, + CIMEError, + ConfigurationError, + ExternalCommandError, + InputError, + LockingError, + RetryableExternalCommandError, + SubmitError, + SystemTestError, +) + + +class TestCIMEError: + """Tests for the base CIMEError exception.""" + + def test_inherits_system_exit(self): + assert issubclass(CIMEError, SystemExit) + + def test_inherits_exception(self): + assert issubclass(CIMEError, Exception) + + def test_catchable_as_exception(self): + with pytest.raises(Exception): + raise CIMEError("test") + + def test_catchable_as_system_exit(self): + with pytest.raises(SystemExit): + raise CIMEError("test") + + def test_message_preserved(self): + try: + raise CIMEError("my message") + except CIMEError as exc: + assert str(exc) == "my message" + + +class TestSubclasses: + """All typed exceptions inherit from CIMEError.""" + + @pytest.mark.parametrize( + "exc_class", + [ + ConfigurationError, + BuildError, + SubmitError, + SystemTestError, + LockingError, + ExternalCommandError, + RetryableExternalCommandError, + InputError, + ], + ) + def test_is_cime_error(self, exc_class): + assert issubclass(exc_class, CIMEError) + + @pytest.mark.parametrize( + "exc_class", + [ + ConfigurationError, + BuildError, + SubmitError, + SystemTestError, + LockingError, + InputError, + ], + ) + def test_simple_subclass_message(self, exc_class): + with pytest.raises(exc_class, match="oops"): + raise exc_class("oops") + + +class TestExternalCommandError: + """Tests for ExternalCommandError and its retryable variant.""" + + def test_stores_returncode_and_cmd(self): + exc = ExternalCommandError("failed", returncode=42, cmd="make all") + assert exc.returncode == 42 + assert exc.cmd == "make all" + assert str(exc) == "failed" + + def test_stores_output_and_stderr(self): + exc = ExternalCommandError( + "failed", + returncode=1, + cmd="make", + output="Building...", + stderr="error: missing file", + ) + assert exc.output == "Building..." + assert exc.stderr == "error: missing file" + + def test_defaults(self): + exc = ExternalCommandError("failed") + assert exc.returncode == 1 + assert exc.cmd == "" + assert exc.output == "" + assert exc.stderr == "" + + def test_command_property_deprecated(self): + """The command property is deprecated but still works.""" + exc = ExternalCommandError("failed", cmd="make") + assert exc.command == "make" # Deprecated property + assert exc.cmd == "make" # Preferred attribute + + def test_command_kwarg_deprecated(self): + """The command kwarg is deprecated but still works.""" + exc = ExternalCommandError("failed", command="make") + assert exc.cmd == "make" + assert exc.command == "make" + + def test_retryable_is_external_command_error(self): + assert issubclass(RetryableExternalCommandError, ExternalCommandError) + + def test_retryable_stores_fields(self): + exc = RetryableExternalCommandError( + "timeout", returncode=124, cmd="srun ./model" + ) + assert exc.returncode == 124 + assert exc.cmd == "srun ./model" diff --git a/CIME/tests/test_unit_expected_fails_file.py b/CIME/tests/test_unit_expected_fails_file.py index 1e0e5878e2b..30c9dd3a6e8 100755 --- a/CIME/tests/test_unit_expected_fails_file.py +++ b/CIME/tests/test_unit_expected_fails_file.py @@ -5,7 +5,7 @@ import shutil import tempfile from CIME.XML.expected_fails_file import ExpectedFailsFile -from CIME.utils import CIMEError +from CIME.core.exceptions import CIMEError from CIME.expected_fails import ExpectedFails diff --git a/CIME/tests/test_unit_fake_case.py b/CIME/tests/test_unit_fake_case.py index c4cebebcd2c..b7b20ac6abc 100755 --- a/CIME/tests/test_unit_fake_case.py +++ b/CIME/tests/test_unit_fake_case.py @@ -7,7 +7,8 @@ import unittest import os -from CIME.utils import get_model, CIMEError, GLOBAL, get_src_root +from CIME.core.exceptions import CIMEError +from CIME.utils import get_model, GLOBAL, get_src_root from CIME.BuildTools.configure import FakeCase diff --git a/CIME/tests/test_unit_grids.py b/CIME/tests/test_unit_grids.py index 8417177e83e..e33349d52b5 100755 --- a/CIME/tests/test_unit_grids.py +++ b/CIME/tests/test_unit_grids.py @@ -19,7 +19,7 @@ import string import tempfile from CIME.XML.grids import Grids, _ComponentGrids, _add_grid_info, _strip_grid_from_name -from CIME.utils import CIMEError +from CIME.core.exceptions import CIMEError class TestGrids(unittest.TestCase): diff --git a/CIME/tests/test_unit_locked_files.py b/CIME/tests/test_unit_locked_files.py index fc871e3b768..2732f16f3f0 100644 --- a/CIME/tests/test_unit_locked_files.py +++ b/CIME/tests/test_unit_locked_files.py @@ -4,7 +4,7 @@ from pathlib import Path from CIME import locked_files -from CIME.utils import CIMEError +from CIME.core.exceptions import CIMEError from CIME.XML.entry_id import EntryID from CIME.XML.env_batch import EnvBatch from CIME.XML.files import Files diff --git a/CIME/tests/test_unit_user_mod_support.py b/CIME/tests/test_unit_user_mod_support.py index 51ffb4778ca..c2d0fc37e13 100755 --- a/CIME/tests/test_unit_user_mod_support.py +++ b/CIME/tests/test_unit_user_mod_support.py @@ -5,7 +5,7 @@ import tempfile import os from CIME.user_mod_support import apply_user_mods -from CIME.utils import CIMEError +from CIME.core.exceptions import CIMEError # ======================================================================== # Define some parameters diff --git a/CIME/tests/test_unit_xml_env_batch.py b/CIME/tests/test_unit_xml_env_batch.py index 3599cfe1dfc..5b1295c4a68 100755 --- a/CIME/tests/test_unit_xml_env_batch.py +++ b/CIME/tests/test_unit_xml_env_batch.py @@ -6,7 +6,8 @@ from contextlib import ExitStack from unittest import mock -from CIME.utils import CIMEError, expect +from CIME.core.exceptions import CIMEError +from CIME.utils import expect from CIME.XML.env_batch import EnvBatch, get_job_deps from CIME.XML.env_workflow import EnvWorkflow from CIME.BuildTools.configure import FakeCase diff --git a/CIME/tests/test_unit_xml_grids.py b/CIME/tests/test_unit_xml_grids.py index 17c01e355de..cceae5b419d 100644 --- a/CIME/tests/test_unit_xml_grids.py +++ b/CIME/tests/test_unit_xml_grids.py @@ -6,10 +6,9 @@ from pathlib import Path from unittest import mock -from CIME.utils import CIMEError +from CIME.core.exceptions import CIMEError from CIME.XML.grids import Grids - TEST_CONFIG = """ diff --git a/CIME/utils.py b/CIME/utils.py index e4a49c4457e..948632b4068 100644 --- a/CIME/utils.py +++ b/CIME/utils.py @@ -152,8 +152,10 @@ def __exit__(self, *args): # hate seeing. It's a subclass of Exception because we want it to be # "catchable". If you are debugging CIME and want to see the stacktrace, # run your CIME command with the --debug flag. -class CIMEError(SystemExit, Exception): - pass +# +# Canonical definition lives in CIME.core.exceptions; re-exported here +# for backward compatibility. +from CIME.core.exceptions import CIMEError # noqa: F401 def expect(condition, error_msg, exc_type=CIMEError, error_prefix="ERROR:"): diff --git a/CIME/wait_for_tests.py b/CIME/wait_for_tests.py index 1f6940138a9..a32278ed052 100644 --- a/CIME/wait_for_tests.py +++ b/CIME/wait_for_tests.py @@ -7,7 +7,8 @@ import xml.etree.ElementTree as xmlet import CIME.utils -from CIME.utils import expect, Timeout, run_cmd, run_cmd_no_fail, safe_copy, CIMEError +from CIME.core.exceptions import CIMEError +from CIME.utils import expect, Timeout, run_cmd, run_cmd_no_fail, safe_copy from CIME.XML.machines import Machines from CIME.test_status import * from CIME.provenance import save_test_success @@ -19,6 +20,7 @@ SLEEP_INTERVAL_SEC = 0.1 ENV_VAR_KEEP_CDASH = "CIME_TEST_CDASH_WFT" + ############################################################################### def signal_handler(*_): ###############################################################################