Skip to content

climate_ref_core.testing #

Test infrastructure for diagnostic testing.

This module provides: - TestCase and TestDataSpecification for defining test scenarios - YAML serialization for dataset catalogs (with local paths stored separately, under the cache)

TestCase #

A single test case for a diagnostic.

Test cases define scenarios for testing, with data resolved via: - requests: ESGF requests to fetch data (use ref test-cases fetch) - datasets_file: Path to a pre-built catalog YAML file

Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
@frozen
class TestCase:
    """
    A single test case for a diagnostic.

    Test cases define scenarios for testing, with data resolved via:
    - `requests`: ESGF requests to fetch data (use `ref test-cases fetch`)
    - `datasets_file`: Path to a pre-built catalog YAML file
    """

    name: str
    """Name of the test case (e.g., 'default', 'short-timeseries')."""

    description: str
    """Human-readable description of what this test case covers."""

    requests: tuple[ESGFRequest, ...] | None = None
    """Optional ESGF requests to fetch data for this test case."""

    datasets_file: str | None = None
    """Path to YAML file with dataset specification (relative to package)."""

datasets_file = None class-attribute instance-attribute #

Path to YAML file with dataset specification (relative to package).

description instance-attribute #

Human-readable description of what this test case covers.

name instance-attribute #

Name of the test case (e.g., 'default', 'short-timeseries').

requests = None class-attribute instance-attribute #

Optional ESGF requests to fetch data for this test case.

TestCasePaths #

Path resolver for test case data.

Provides access to all paths within a test case directory: - catalog.yaml: Dataset metadata (tracked in git) - regression/: Regression outputs (tracked in git)

The local-paths sidecar (catalog.paths.yaml) is the exception: it lives under the dataset cache rather than in the package tree. The paths it records are machine-specific and point into the various caches (the dataset cache, the intake-esgf cache), so the sidecar belongs with those caches rather than with a checkout. It is shared by every checkout on the machine, and a fresh checkout cannot lose it.

Can be constructed from: - A diagnostic + test case name (auto-resolves provider's test-data dir) - An explicit test_data_dir + provider slug + diagnostic slug + test case name

Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
@frozen
class TestCasePaths:
    """
    Path resolver for test case data.

    Provides access to all paths within a test case directory:
    - catalog.yaml: Dataset metadata (tracked in git)
    - regression/: Regression outputs (tracked in git)

    The local-paths sidecar (``catalog.paths.yaml``) is the exception: it lives under the
    dataset cache rather than in the package tree. The paths it records are machine-specific
    and point into the various caches (the dataset cache, the intake-esgf cache), so the
    sidecar belongs with those caches rather than with a checkout. It is shared by every
    checkout on the machine, and a fresh checkout cannot lose it.

    Can be constructed from:
    - A diagnostic + test case name (auto-resolves provider's test-data dir)
    - An explicit test_data_dir + provider slug + diagnostic slug + test case name
    """

    root: Path
    """The test case directory (test_data_dir / diagnostic_slug / test_case_name)."""

    provider_slug: str
    """Slug of the provider owning the diagnostic, used to namespace the paths sidecar."""

    @classmethod
    def from_diagnostic(cls, diagnostic: Diagnostic, test_case: str) -> TestCasePaths | None:
        """
        Create from a diagnostic, auto-resolving the provider's test-data directory.

        Returns None if the provider's test-data directory cannot be determined
        (e.g., not a development checkout).

        Parameters
        ----------
        diagnostic
            The diagnostic to get paths for
        test_case
            Test case name (e.g., 'default')
        """
        test_data_dir = _get_provider_test_data_dir(diagnostic)
        if test_data_dir is None:
            return None
        return cls(
            root=test_data_dir / diagnostic.slug / test_case,
            provider_slug=diagnostic.provider.slug,
        )

    @classmethod
    def from_test_data_dir(
        cls,
        test_data_dir: Path,
        diagnostic_slug: str,
        test_case: str,
        provider_slug: str,
    ) -> TestCasePaths:
        """
        Create from an explicit test data directory.

        Use this when you have a test_data_dir fixture (in tests) or
        know the base path explicitly.

        Parameters
        ----------
        test_data_dir
            Base test data directory (e.g., from test fixture)
        diagnostic_slug
            The diagnostic slug
        test_case
            Test case name (e.g., 'default')
        provider_slug
            The provider slug, used to namespace the paths sidecar
        """
        return cls(root=test_data_dir / diagnostic_slug / test_case, provider_slug=provider_slug)

    @property
    def catalog(self) -> Path:
        """Path to catalog.yaml."""
        return self.root / "catalog.yaml"

    @property
    def catalog_paths(self) -> Path:
        """
        Path to catalog.paths.yaml, under the dataset cache rather than the package tree.

        The sidecar maps each dataset to its local file, so it is a property of the machine,
        not of the checkout. Namespaced by provider because diagnostic slugs are only unique
        within a provider.
        """
        return (
            resolve_cache_dir("test-case-paths")
            / self.provider_slug
            / self.root.parent.name
            / self.root.name
            / "catalog.paths.yaml"
        )

    @property
    def regression(self) -> Path:
        """Path to regression/ directory."""
        return self.root / COMMITTED_DIRNAME

    @property
    def manifest(self) -> Path:
        """Path to manifest.json (the regression bundle manifest, tracked in git)."""
        return self.root / "manifest.json"

    @property
    def output(self) -> Path:
        """Path to the output/ directory (gitignored; holds materialised native slots)."""
        return self.root / "output"

    def output_slot(self, label: str = "latest") -> Path:
        """
        Path to a named output slot under ``output/`` (gitignored).

        A slot is a self-contained, inspectable snapshot of one execute/materialise:
        the curated native set (flat, at manifest-relative paths) plus a ``regression/``
        subdirectory holding the rebuilt committed bundle.
        ``latest`` (the default) is overwritten on every run; named slots persist so two
        runs can be diffed (e.g. ``--label before`` vs ``--label after``).

        Parameters
        ----------
        label
            Slot name. Must be a single path segment (no separators or ``..``).
        """
        return safe_path(label, self.output, label="output slot label", single_segment=True)

    @property
    def test_data_dir(self) -> Path:
        """Path to the test-data directory (parent of diagnostic slug dir)."""
        return self.root.parent.parent

    def exists(self) -> bool:
        """Check if the test case directory exists."""
        return self.root.exists()

    def create(self) -> None:
        """Create the test case directory if it doesn't exist."""
        self.root.mkdir(parents=True, exist_ok=True)

catalog property #

Path to catalog.yaml.

catalog_paths property #

Path to catalog.paths.yaml, under the dataset cache rather than the package tree.

The sidecar maps each dataset to its local file, so it is a property of the machine, not of the checkout. Namespaced by provider because diagnostic slugs are only unique within a provider.

manifest property #

Path to manifest.json (the regression bundle manifest, tracked in git).

output property #

Path to the output/ directory (gitignored; holds materialised native slots).

provider_slug instance-attribute #

Slug of the provider owning the diagnostic, used to namespace the paths sidecar.

regression property #

Path to regression/ directory.

root instance-attribute #

The test case directory (test_data_dir / diagnostic_slug / test_case_name).

test_data_dir property #

Path to the test-data directory (parent of diagnostic slug dir).

create() #

Create the test case directory if it doesn't exist.

Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
def create(self) -> None:
    """Create the test case directory if it doesn't exist."""
    self.root.mkdir(parents=True, exist_ok=True)

exists() #

Check if the test case directory exists.

Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
def exists(self) -> bool:
    """Check if the test case directory exists."""
    return self.root.exists()

from_diagnostic(diagnostic, test_case) classmethod #

Create from a diagnostic, auto-resolving the provider's test-data directory.

Returns None if the provider's test-data directory cannot be determined (e.g., not a development checkout).

Parameters:

Name Type Description Default
diagnostic Diagnostic

The diagnostic to get paths for

required
test_case str

Test case name (e.g., 'default')

required
Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
@classmethod
def from_diagnostic(cls, diagnostic: Diagnostic, test_case: str) -> TestCasePaths | None:
    """
    Create from a diagnostic, auto-resolving the provider's test-data directory.

    Returns None if the provider's test-data directory cannot be determined
    (e.g., not a development checkout).

    Parameters
    ----------
    diagnostic
        The diagnostic to get paths for
    test_case
        Test case name (e.g., 'default')
    """
    test_data_dir = _get_provider_test_data_dir(diagnostic)
    if test_data_dir is None:
        return None
    return cls(
        root=test_data_dir / diagnostic.slug / test_case,
        provider_slug=diagnostic.provider.slug,
    )

from_test_data_dir(test_data_dir, diagnostic_slug, test_case, provider_slug) classmethod #

Create from an explicit test data directory.

Use this when you have a test_data_dir fixture (in tests) or know the base path explicitly.

Parameters:

Name Type Description Default
test_data_dir Path

Base test data directory (e.g., from test fixture)

required
diagnostic_slug str

The diagnostic slug

required
test_case str

Test case name (e.g., 'default')

required
provider_slug str

The provider slug, used to namespace the paths sidecar

required
Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
@classmethod
def from_test_data_dir(
    cls,
    test_data_dir: Path,
    diagnostic_slug: str,
    test_case: str,
    provider_slug: str,
) -> TestCasePaths:
    """
    Create from an explicit test data directory.

    Use this when you have a test_data_dir fixture (in tests) or
    know the base path explicitly.

    Parameters
    ----------
    test_data_dir
        Base test data directory (e.g., from test fixture)
    diagnostic_slug
        The diagnostic slug
    test_case
        Test case name (e.g., 'default')
    provider_slug
        The provider slug, used to namespace the paths sidecar
    """
    return cls(root=test_data_dir / diagnostic_slug / test_case, provider_slug=provider_slug)

output_slot(label='latest') #

Path to a named output slot under output/ (gitignored).

A slot is a self-contained, inspectable snapshot of one execute/materialise: the curated native set (flat, at manifest-relative paths) plus a regression/ subdirectory holding the rebuilt committed bundle. latest (the default) is overwritten on every run; named slots persist so two runs can be diffed (e.g. --label before vs --label after).

Parameters:

Name Type Description Default
label str

Slot name. Must be a single path segment (no separators or ..).

'latest'
Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
def output_slot(self, label: str = "latest") -> Path:
    """
    Path to a named output slot under ``output/`` (gitignored).

    A slot is a self-contained, inspectable snapshot of one execute/materialise:
    the curated native set (flat, at manifest-relative paths) plus a ``regression/``
    subdirectory holding the rebuilt committed bundle.
    ``latest`` (the default) is overwritten on every run; named slots persist so two
    runs can be diffed (e.g. ``--label before`` vs ``--label after``).

    Parameters
    ----------
    label
        Slot name. Must be a single path segment (no separators or ``..``).
    """
    return safe_path(label, self.output, label="output slot label", single_segment=True)

TestDataSpecification #

Test data specification for a diagnostic.

Contains multiple named test cases for testing different input datasets.

Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
@frozen
class TestDataSpecification:
    """
    Test data specification for a diagnostic.

    Contains multiple named test cases for testing different input datasets.
    """

    test_cases: tuple[TestCase, ...] = field(factory=tuple)
    """Collection of test cases for this diagnostic."""

    def get_case(self, name: str) -> TestCase:
        """
        Get a test case by name.

        Parameters
        ----------
        name
            Name of the test case to retrieve

        Returns
        -------
        TestCase
            The matching test case

        Raises
        ------
        StopIteration
            If no test case with that name exists
        """
        return next(tc for tc in self.test_cases if tc.name == name)

    def has_case(self, name: str) -> bool:
        """
        Check if a test case with the given name exists.

        Parameters
        ----------
        name
            Name of the test case to check

        Returns
        -------
        bool
            True if the test case exists
        """
        return any(tc.name == name for tc in self.test_cases)

    @property
    def case_names(self) -> list[str]:
        """Get names of all test cases."""
        return [tc.name for tc in self.test_cases]

case_names property #

Get names of all test cases.

test_cases = field(factory=tuple) class-attribute instance-attribute #

Collection of test cases for this diagnostic.

get_case(name) #

Get a test case by name.

Parameters:

Name Type Description Default
name str

Name of the test case to retrieve

required

Returns:

Type Description
TestCase

The matching test case

Raises:

Type Description
StopIteration

If no test case with that name exists

Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
def get_case(self, name: str) -> TestCase:
    """
    Get a test case by name.

    Parameters
    ----------
    name
        Name of the test case to retrieve

    Returns
    -------
    TestCase
        The matching test case

    Raises
    ------
    StopIteration
        If no test case with that name exists
    """
    return next(tc for tc in self.test_cases if tc.name == name)

has_case(name) #

Check if a test case with the given name exists.

Parameters:

Name Type Description Default
name str

Name of the test case to check

required

Returns:

Type Description
bool

True if the test case exists

Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
def has_case(self, name: str) -> bool:
    """
    Check if a test case with the given name exists.

    Parameters
    ----------
    name
        Name of the test case to check

    Returns
    -------
    bool
        True if the test case exists
    """
    return any(tc.name == name for tc in self.test_cases)

catalog_changed_since_regression(paths) #

Check if the catalog has changed since regression data was generated.

The baseline's input hash is read from manifest.json (catalog_hash), the single coupling record; there is no separate sidecar.

Returns True if: - No regression data exists (new test case) - No manifest, or the manifest records no catalog_hash (legacy regression data) - No catalog file exists - The current catalog hash differs from the one recorded in the manifest

Parameters:

Name Type Description Default
paths TestCasePaths

TestCasePaths for the test case

required

Returns:

Type Description
bool

True if regression should be regenerated, False otherwise

Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
def catalog_changed_since_regression(paths: TestCasePaths) -> bool:
    """
    Check if the catalog has changed since regression data was generated.

    The baseline's input hash is read from ``manifest.json`` (``catalog_hash``),
    the single coupling record; there is no separate sidecar.

    Returns True if:
    - No regression data exists (new test case)
    - No manifest, or the manifest records no ``catalog_hash`` (legacy regression data)
    - No catalog file exists
    - The current catalog hash differs from the one recorded in the manifest

    Parameters
    ----------
    paths
        TestCasePaths for the test case

    Returns
    -------
    :
        True if regression should be regenerated, False otherwise
    """
    if not paths.regression.exists():
        return True  # No regression data, needs to run
    if not paths.catalog.exists():
        return True  # No catalog file, needs to run
    if not paths.manifest.exists():
        return True  # No manifest, needs to run

    stored_hash = Manifest.load(paths.manifest).catalog_hash
    if stored_hash is None:
        return True  # Legacy manifest without a recorded catalog hash, needs to run

    return stored_hash != get_catalog_hash(paths.catalog)

collect_test_case_params(provider) #

Collect all diagnostic/test_case pairs from a provider for parameterized testing.

Returns a list of pytest.param objects with (diagnostic, test_case_name) tuples, each with an id of "{diagnostic.slug}/{test_case.name}".

Parameters:

Name Type Description Default
provider DiagnosticProvider

The diagnostic provider to collect test cases from

required

Returns:

Type Description
list[ParameterSet]

List of pytest.param objects for use with @pytest.mark.parametrize

Example
from climate_ref_core.testing import collect_test_case_params
from my_provider import provider

test_case_params = collect_test_case_params(provider)


@pytest.mark.parametrize("diagnostic,test_case_name", test_case_params)
def test_my_test(diagnostic, test_case_name): ...
Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
def collect_test_case_params(provider: DiagnosticProvider) -> list[ParameterSet]:
    """
    Collect all diagnostic/test_case pairs from a provider for parameterized testing.

    Returns a list of pytest.param objects with (diagnostic, test_case_name) tuples,
    each with an id of "{diagnostic.slug}/{test_case.name}".

    Parameters
    ----------
    provider
        The diagnostic provider to collect test cases from

    Returns
    -------
    :
        List of pytest.param objects for use with @pytest.mark.parametrize

    Example
    -------
    ```python
    from climate_ref_core.testing import collect_test_case_params
    from my_provider import provider

    test_case_params = collect_test_case_params(provider)


    @pytest.mark.parametrize("diagnostic,test_case_name", test_case_params)
    def test_my_test(diagnostic, test_case_name): ...
    ```
    """
    import pytest  # noqa: PLC0415

    params: list[ParameterSet] = []
    for diagnostic in provider.diagnostics():
        if diagnostic.test_data_spec is None:
            continue
        for test_case in diagnostic.test_data_spec.test_cases:
            params.append(
                pytest.param(
                    diagnostic,
                    test_case.name,
                    id=f"{diagnostic.slug}/{test_case.name}",
                )
            )
    return params

excluded_test_case_diagnostics() #

Diagnostic identifiers to skip when fetching or running test cases.

Reads the REF_TEST_CASES_SKIP environment variable as a comma-separated list of diagnostic slugs or provider/diagnostic pairs.

Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
def excluded_test_case_diagnostics() -> set[str]:
    """
    Diagnostic identifiers to skip when fetching or running test cases.

    Reads the ``REF_TEST_CASES_SKIP`` environment variable
    as a comma-separated list of ``diagnostic`` slugs or ``provider/diagnostic`` pairs.
    """
    raw = os.environ.get("REF_TEST_CASES_SKIP", "")
    return {token.strip() for token in raw.split(",") if token.strip()}

get_catalog_hash(path) #

Get the hash stored in an existing catalog file.

Parameters:

Name Type Description Default
path Path

Path to the catalog YAML file

required

Returns:

Type Description
str | None

The hash string if found, None if file doesn't exist or has no hash

Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
def get_catalog_hash(path: Path) -> str | None:
    """
    Get the hash stored in an existing catalog file.

    Parameters
    ----------
    path
        Path to the catalog YAML file

    Returns
    -------
    :
        The hash string if found, None if file doesn't exist or has no hash
    """
    if not path.exists():
        return None
    with open(path) as f:
        data = yaml.safe_load(f)
    if data is None:
        return None
    hash_value = data.get("_metadata", {}).get("hash")
    return str(hash_value) if hash_value is not None else None

is_test_case_excluded(provider_slug, diagnostic_slug) #

Return True if the diagnostic is excluded via REF_TEST_CASES_SKIP.

Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
def is_test_case_excluded(provider_slug: str, diagnostic_slug: str) -> bool:
    """Return True if the diagnostic is excluded via ``REF_TEST_CASES_SKIP``."""
    excluded = excluded_test_case_diagnostics()
    return diagnostic_slug in excluded or f"{provider_slug}/{diagnostic_slug}" in excluded

load_datasets_from_yaml(path, paths_file) #

Load ExecutionDatasetCollection from a YAML file.

The YAML file structure:

cmip6:
  slug_column: instance_id
  selector:
    source_id: ACCESS-ESM1-5
  datasets:
    - instance_id: CMIP6.CMIP...
      variable_id: tas
      filename: tas_Amon_ACCESS-ESM1-5_historical_r1i1p1f1_gn_185001-201412.nc
      # ... other metadata

Paths are loaded from paths_file if it exists, allowing the main catalog to be version-controlled while paths remain machine-specific. Multi-file datasets have multiple rows with paths keyed by {instance_id}::{filename}.

Parameters:

Name Type Description Default
path Path

Path to the catalog YAML file

required
paths_file Path

Path to the local-paths sidecar (see :attr:TestCasePaths.catalog_paths). If it does not exist, the loaded datasets carry no path column.

required
Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
def load_datasets_from_yaml(path: Path, paths_file: Path) -> ExecutionDatasetCollection:
    """
    Load ExecutionDatasetCollection from a YAML file.

    The YAML file structure:

    ```yaml
    cmip6:
      slug_column: instance_id
      selector:
        source_id: ACCESS-ESM1-5
      datasets:
        - instance_id: CMIP6.CMIP...
          variable_id: tas
          filename: tas_Amon_ACCESS-ESM1-5_historical_r1i1p1f1_gn_185001-201412.nc
          # ... other metadata
    ```

    Paths are loaded from ``paths_file`` if it exists, allowing the main catalog to be
    version-controlled while paths remain machine-specific. Multi-file datasets have
    multiple rows with paths keyed by `{instance_id}::{filename}`.

    Parameters
    ----------
    path
        Path to the catalog YAML file
    paths_file
        Path to the local-paths sidecar (see :attr:`TestCasePaths.catalog_paths`).
        If it does not exist, the loaded datasets carry no ``path`` column.
    """
    with open(path) as f:
        data = yaml.safe_load(f)

    paths_map: dict[str, str] = {}
    if paths_file.exists():
        with open(paths_file) as f:
            paths_map = yaml.safe_load(f) or {}

    collections: dict[SourceDatasetType | str, DatasetCollection] = {}

    for source_type_str, source_data in data.items():
        if source_type_str == "_metadata":
            continue  # Skip metadata section
        source_type = SourceDatasetType(source_type_str)
        selector_dict = source_data.get("selector", {})
        selector: Selector = tuple(sorted(selector_dict.items()))
        datasets_list = source_data.get("datasets", [])
        slug_column = source_data.get("slug_column", "instance_id")

        # Merge paths from paths file using composite key
        for dataset in datasets_list:
            instance_id = dataset.get(slug_column)
            filename = dataset.get("filename")
            if instance_id and filename:
                # Try composite key first (new format for multi-file datasets)
                composite_key = f"{instance_id}::{filename}"
                if composite_key in paths_map:
                    dataset["path"] = paths_map[composite_key]
                elif instance_id in paths_map:
                    # Fall back to simple key for backward compatibility
                    dataset["path"] = paths_map[instance_id]
            elif instance_id and instance_id in paths_map:
                # Legacy format without filename
                dataset["path"] = paths_map[instance_id]

        collections[source_type] = DatasetCollection(
            datasets=pd.DataFrame(datasets_list),
            slug_column=slug_column,
            selector=selector,
        )

    return ExecutionDatasetCollection(collections)

save_datasets_to_yaml(datasets, path, paths_file, *, force=False) #

Save ExecutionDatasetCollection to a YAML file.

Paths are saved to a separate sidecar file to allow the main catalog to be version-controlled while paths remain machine-specific.

Multi-file datasets (e.g., time-chunked data) are stored as multiple rows, one per file. Paths are keyed by {instance_id}::{filename} to support multiple files per dataset.

By default, the catalog is only written if the content has changed (detected via hash comparison). Use force=True to always write.

The paths sidecar is regenerated on every save, even when the catalog content is unchanged. Local cache contents can change independently of the version-controlled catalog, so retaining an existing sidecar can leave missing or stale paths behind.

Parameters:

Name Type Description Default
datasets ExecutionDatasetCollection

The datasets to save

required
path Path

Path to write the YAML file

required
paths_file Path

Path to write the local-paths sidecar (see :attr:TestCasePaths.catalog_paths)

required
force bool

If True, always write the catalog even if unchanged

False

Returns:

Type Description
bool

True if the catalog was (re)written, False if the catalog was left unchanged (the paths sidecar may still have been regenerated).

Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
def save_datasets_to_yaml(
    datasets: ExecutionDatasetCollection,
    path: Path,
    paths_file: Path,
    *,
    force: bool = False,
) -> bool:
    """
    Save ExecutionDatasetCollection to a YAML file.

    Paths are saved to a separate sidecar file to allow the main catalog to be
    version-controlled while paths remain machine-specific.

    Multi-file datasets (e.g., time-chunked data) are stored as multiple rows,
    one per file. Paths are keyed by `{instance_id}::{filename}` to support
    multiple files per dataset.

    By default, the catalog is only written if the content has changed
    (detected via hash comparison). Use `force=True` to always write.

    The paths sidecar is regenerated on every save, even when the catalog content is
    unchanged. Local cache contents can change independently of the version-controlled
    catalog, so retaining an existing sidecar can leave missing or stale paths behind.

    Parameters
    ----------
    datasets
        The datasets to save
    path
        Path to write the YAML file
    paths_file
        Path to write the local-paths sidecar (see :attr:`TestCasePaths.catalog_paths`)
    force
        If True, always write the catalog even if unchanged

    Returns
    -------
    :
        True if the catalog was (re)written, False if the catalog was left unchanged
        (the paths sidecar may still have been regenerated).
    """
    new_hash = datasets.hash

    data, paths_map = _serialise_datasets(datasets)

    if not force and get_catalog_hash(path) == new_hash:
        # Keep the tracked catalog byte-identical, but always refresh its machine-local
        # paths. An existing sidecar may be partial or point at an old cache location.
        _write_paths_file(paths_file, paths_map)
        logger.info(f"Catalog unchanged, refreshed paths file: {paths_file}")
        return False

    path.parent.mkdir(parents=True, exist_ok=True)
    with open(path, "w") as f:
        yaml.dump(data, f, default_flow_style=False, sort_keys=False)
    _write_paths_file(paths_file, paths_map)
    logger.info(f"Saved catalog to {path} (paths: {paths_file})")
    return True

validate_catalog_paths(path, paths_file) #

Load a catalog and check that every row resolves to a local file.

Loading remains permissive because callers also use catalogs for metadata-only operations. Fetch and execution entry points can call this function when they require usable input files.

Parameters:

Name Type Description Default
path Path

Path to the catalog YAML file.

required
paths_file Path

Path to the machine-local paths sidecar.

required

Returns:

Type Description
ExecutionDatasetCollection

The loaded datasets, so callers need not parse the catalog a second time.

Raises:

Type Description
DatasetResolutionError

If the sidecar is absent for a non-empty catalog, incomplete, or points to missing files.

Source code in packages/climate-ref-core/src/climate_ref_core/testing.py
def validate_catalog_paths(path: Path, paths_file: Path) -> ExecutionDatasetCollection:
    """Load a catalog and check that every row resolves to a local file.

    Loading remains permissive because callers also use catalogs for metadata-only
    operations. Fetch and execution entry points can call this function when they
    require usable input files.

    Parameters
    ----------
    path
        Path to the catalog YAML file.
    paths_file
        Path to the machine-local paths sidecar.

    Returns
    -------
    :
        The loaded datasets, so callers need not parse the catalog a second time.

    Raises
    ------
    DatasetResolutionError
        If the sidecar is absent for a non-empty catalog, incomplete, or points to missing files.
    """
    from climate_ref_core.exceptions import DatasetResolutionError  # noqa: PLC0415

    try:
        datasets = load_datasets_from_yaml(path, paths_file)
    except Exception as e:
        raise DatasetResolutionError(f"Could not load catalog paths for {path}: {e}") from e

    has_rows = any(len(collection.datasets) for collection in datasets.values())
    if has_rows and not paths_file.exists():
        raise DatasetResolutionError(
            f"Paths file is missing for catalog {path}: {paths_file}. "
            "Run `ref test-cases fetch` to rebuild it."
        )

    missing_entries: list[str] = []
    missing_files: list[str] = []
    for source_type, collection in datasets.items():
        for _, dataset in collection.datasets.iterrows():
            instance_id = dataset.get(collection.slug_column)
            filename = dataset.get("filename")
            key = f"{instance_id}::{filename}" if instance_id and filename else instance_id
            resolved_path = dataset.get("path")
            label = f"{source_type.value}:{key or '<unknown dataset>'}"
            if not isinstance(resolved_path, (str, Path)) or not str(resolved_path):
                missing_entries.append(label)
            elif not Path(resolved_path).is_file():
                missing_files.append(f"{label} -> {resolved_path}")

    if missing_entries or missing_files:
        problems = []
        if missing_entries:
            problems.append(f"missing path entries: {', '.join(missing_entries)}")
        if missing_files:
            problems.append(f"files not found: {', '.join(missing_files)}")
        raise DatasetResolutionError(
            f"Catalog paths are incomplete for {path} ({'. '.join(problems)}). "
            "Run `ref test-cases fetch` to rebuild the paths file."
        )
    return datasets