Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CIME/SystemTests/test_mods.py
Original file line number Diff line number Diff line change
@@ -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__)
Expand Down
2 changes: 1 addition & 1 deletion CIME/XML/compsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down
3 changes: 2 additions & 1 deletion CIME/XML/machines.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions CIME/XML/namelist_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,7 +32,6 @@


class CaseInsensitiveDict(dict):

"""Basic case insensitive dict with strings only keys.
From https://stackoverflow.com/a/27890005"""

Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion CIME/XML/tests.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
3 changes: 2 additions & 1 deletion CIME/case/case_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion CIME/case/case_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion CIME/compare_namelists.py
Original file line number Diff line number Diff line change
@@ -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):
###############################################################################
Expand Down
7 changes: 7 additions & 0 deletions CIME/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""
CIME core package — 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.
"""
1 change: 1 addition & 0 deletions CIME/core/config/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Configuration bootstrap and loading utilities."""
150 changes: 150 additions & 0 deletions CIME/core/config/bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""
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
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 = os.path.join(root, "CIME", "Tools")

# Build the list of paths to ensure are in sys.path
paths_to_add: List[str] = [root, tools_path]
if extra_paths:
paths_to_add.extend(str(p) for p in extra_paths)

_ensure_paths(paths_to_add)

if set_env:
os.environ["CIMEROOT"] = root

return root


def _ensure_paths(paths: Sequence[str]) -> None:
"""Ensure each path is in sys.path, inserted at the front in order.

Paths already present are moved to the correct position rather than
duplicated.
"""
for i, p in enumerate(paths):
absp = os.path.abspath(p)
# Remove existing entry if present (to re-position)
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.")
81 changes: 81 additions & 0 deletions CIME/core/exceptions.py
Original file line number Diff line number Diff line change
@@ -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)."""
3 changes: 2 additions & 1 deletion CIME/hist_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Functions for actions pertaining to history files.
"""

import logging
import os
import re
Expand All @@ -17,7 +18,7 @@
SharedArea,
parse_test_name,
)
from CIME.utils import CIMEError
from CIME.core.exceptions import CIMEError

logger = logging.getLogger(__name__)

Expand Down
2 changes: 1 addition & 1 deletion CIME/tests/test_unit_bless_test_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion CIME/tests/test_unit_case_run.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down
Loading
Loading