diff --git a/esmvalcore/io/local.py b/esmvalcore/io/local.py index aad0d7dccf..b0ae5b81d5 100644 --- a/esmvalcore/io/local.py +++ b/esmvalcore/io/local.py @@ -58,7 +58,6 @@ from netCDF4 import Dataset import esmvalcore.io.protocol -from esmvalcore.exceptions import RecipeError from esmvalcore.iris_helpers import ignore_warnings_context if TYPE_CHECKING: @@ -421,6 +420,30 @@ def _select_files( return selection +class _MissingFacetError(KeyError): + """Error raised when a facet required for filling the template is missing.""" + + +def _format_iterable(iterable: Iterable[Any]) -> str: + """Format an iterable as a string for use in messages. + + Parameters + ---------- + iterable: + The iterable to format. + + Returns + ------- + : + The formatted string. + """ + items = [f"'{item}'" for item in sorted(iterable)] + if len(items) > 1: + items[-1] = f"and {items[-1]}" + txt = " ".join(items) if len(items) == 2 else ", ".join(items) + return f"s {txt}" if len(items) > 1 else f" {txt}" + + def _replace_tags( paths: str | list[str], variable: Facets, @@ -446,6 +469,7 @@ def _replace_tags( tlist.add("sub_experiment") pathset = new_paths + failed = set() for original_tag in tlist: tag, _, _ = _get_caps_options(original_tag) @@ -454,12 +478,17 @@ def _replace_tags( elif tag == "version": replacewith = "*" else: - msg = ( - f"Dataset key '{tag}' must be specified for {variable}, check " - f"your recipe entry and/or extra facet file(s)" - ) - raise RecipeError(msg) + failed.add(tag) + continue pathset = _replace_tag(pathset, original_tag, replacewith) + if failed: + msg = ( + f"Unable to complete path{_format_iterable(pathset)} because " + f"the facet{_format_iterable(failed)}" + + (" has" if len(failed) == 1 else " have") + + " not been specified." + ) + raise _MissingFacetError(msg) return [Path(p) for p in pathset] @@ -566,7 +595,11 @@ def find_data(self, **facets: FacetValue) -> list[LocalFile]: if "original_short_name" in facets: facets["short_name"] = facets["original_short_name"] - globs = self._get_glob_patterns(**facets) + try: + globs = self._get_glob_patterns(**facets) + except _MissingFacetError as exc: + self.debug_info = exc.args[0] + return [] self.debug_info = "No files found matching glob pattern " + "\n".join( str(g) for g in globs ) diff --git a/esmvalcore/local.py b/esmvalcore/local.py index 97c3822412..88587a4e9e 100644 --- a/esmvalcore/local.py +++ b/esmvalcore/local.py @@ -21,10 +21,12 @@ get_project_config, load_config_developer, ) +from esmvalcore.exceptions import RecipeError from esmvalcore.io.local import ( LocalDataSource, LocalFile, _filter_versions_called_latest, + _MissingFacetError, _replace_tags, _select_latest_version, ) @@ -300,7 +302,10 @@ def _get_output_file(variable: dict[str, Any], preproc_dir: Path) -> Path: if isinstance(variable.get("exp"), (list, tuple)): variable = dict(variable) variable["exp"] = "-".join(variable["exp"]) - outfile = _replace_tags(cfg["output_file"], variable)[0] + try: + outfile = _replace_tags(cfg["output_file"], variable)[0] + except _MissingFacetError as exc: + raise RecipeError(exc.args[0]) from exc if "timerange" in variable: timerange = variable["timerange"].replace("/", "-") outfile = Path(f"{outfile}_{timerange}") diff --git a/tests/integration/io/test_local.py b/tests/integration/io/test_local.py index 71b114f35f..080afed5c7 100644 --- a/tests/integration/io/test_local.py +++ b/tests/integration/io/test_local.py @@ -14,6 +14,7 @@ import esmvalcore.config._config import esmvalcore.local from esmvalcore.config import CFG +from esmvalcore.exceptions import RecipeError from esmvalcore.io.local import ( LocalDataSource, LocalFile, @@ -83,6 +84,30 @@ def test_get_output_file(monkeypatch, cfg): assert output_file == expected +def test_get_output_file_missing_facets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test that a RecipeError is raised if a required facet is missing.""" + monkeypatch.setattr(esmvalcore.cmor.table, "CMOR_TABLES", {}) + monkeypatch.setitem( + CFG, + "config_developer_file", + Path(esmvalcore.__path__[0], "config-developer.yml"), + ) + facets = { + "project": "CMIP6", + "mip": "Amon", + "short_name": "tas", + } + expected_message = ( + "Unable to complete path 'CMIP6_{dataset}_Amon_{exp}_{ensemble}_tas" + "_{grid}' because the facets 'dataset', 'ensemble', 'exp', and 'grid' " + "have not been specified." + ) + with pytest.raises(RecipeError, match=expected_message): + _get_output_file(facets, Path("/preproc/dir")) + + @pytest.mark.parametrize("cfg", CONFIG["get_output_file"]) def test_get_output_file_no_config_developer(monkeypatch, cfg): """Test getting output name for preprocessed files.""" @@ -216,6 +241,31 @@ def test_find_data(root, cfg): assert str(pattern) in data_source.debug_info +def test_find_data_facet_missing() -> None: + """Test that a MissingFacetError is raised if a required facet is missing.""" + data_source = LocalDataSource( + name="test-data-source", + project="CMIP6", + rootpath=Path("/data/cmip6"), + priority=1, + dirname_template="{dataset}/{exp}/{ensemble}", + filename_template="{short_name}.nc", + ) + facets = { + "short_name": "tas", + "dataset": "test-dataset", + "exp": ["historical", "ssp585"], + } + expected_message = ( + "Unable to complete paths 'test-dataset/historical/{ensemble}' and " + "'test-dataset/ssp585/{ensemble}' because the facet 'ensemble' has " + "not been specified." + ) + files = data_source.find_data(**facets) + assert not files + assert data_source.debug_info == expected_message + + def test_select_invalid_drs_structure(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(esmvalcore.cmor.table, "CMOR_TABLES", {}) monkeypatch.setitem( diff --git a/tests/unit/io/local/test_replace_tags.py b/tests/unit/io/local/test_replace_tags.py index 8adb8a83dc..b2f4ff8056 100644 --- a/tests/unit/io/local/test_replace_tags.py +++ b/tests/unit/io/local/test_replace_tags.py @@ -1,11 +1,14 @@ """Tests for `_replace_tags` in `esmvalcore.io.local`.""" +import re from pathlib import Path import pytest -from esmvalcore.exceptions import RecipeError -from esmvalcore.io.local import _replace_tags +from esmvalcore.io.local import ( + _MissingFacetError, + _replace_tags, +) VARIABLE = { "project": "CMIP6", @@ -58,13 +61,28 @@ def test_replace_tags_with_caps(): def test_replace_tags_missing_facet(): - """Check that a RecipeError is raised if a required facet is missing.""" + """Check that a MissingFacetError is raised if a required facet is missing.""" paths = ["{short_name}_{missing}_*.nc"] variable = {"short_name": "tas"} - with pytest.raises(RecipeError) as exc: + expected_message = ( + "Unable to complete path 'tas_{missing}_*.nc' because the facet " + "'missing' has not been specified." + ) + with pytest.raises(_MissingFacetError, match=re.escape(expected_message)): _replace_tags(paths, variable) - assert "Dataset key 'missing' must be specified" in exc.value.message + +def test_replace_tags_missing_facets(): + """Check that a MissingFacetError is raised if multiple facets are missing.""" + paths = ["{missing1}_{short_name}_{missing2}_{missing3}_*.nc"] + variable = {"short_name": "tas"} + expected_message = ( + "Unable to complete path '{missing1}_tas_{missing2}_{missing3}_*.nc' " + "because the facets 'missing1', 'missing2', and 'missing3' have not " + "been specified." + ) + with pytest.raises(_MissingFacetError, match=re.escape(expected_message)): + _replace_tags(paths, variable) def test_replace_tags_list_of_str():