From 35c4afe9c8ffba51e64d0b217f5fc366bea81476 Mon Sep 17 00:00:00 2001 From: Bouwe Andela Date: Thu, 19 Dec 2024 18:28:30 +0100 Subject: [PATCH] Add ESMValTool example metric (#40) --- .github/workflows/ci.yaml | 1 + Makefile | 9 +- changelog/40.feature.md | 1 + docs/gen_doc_stubs.py | 1 + packages/ref-metrics-esmvaltool/README.md | 8 + .../ref-metrics-esmvaltool/pyproject.toml | 40 ++++ .../src/ref_metrics_esmvaltool/__init__.py | 16 ++ .../src/ref_metrics_esmvaltool/example.py | 108 +++++++++ .../src/ref_metrics_esmvaltool/py.typed | 0 .../src/ref_metrics_esmvaltool/recipe.py | 210 ++++++++++++++++++ .../src/ref_metrics_esmvaltool/recipes.txt | 1 + .../tests/unit/test_metrics.py | 64 ++++++ .../tests/unit/test_provider.py | 15 ++ packages/ref/src/ref/provider_registry.py | 8 +- packages/ref/tests/unit/cli/test_datasets.py | 1 + packages/ref/tests/unit/test_solver.py | 2 +- pyproject.toml | 2 + tests/integration/test_solve.py | 29 ++- uv.lock | 112 ++++++++++ 19 files changed, 621 insertions(+), 7 deletions(-) create mode 100644 changelog/40.feature.md create mode 100644 packages/ref-metrics-esmvaltool/README.md create mode 100644 packages/ref-metrics-esmvaltool/pyproject.toml create mode 100644 packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/__init__.py create mode 100644 packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/example.py create mode 100644 packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/py.typed create mode 100644 packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/recipe.py create mode 100644 packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/recipes.txt create mode 100644 packages/ref-metrics-esmvaltool/tests/unit/test_metrics.py create mode 100644 packages/ref-metrics-esmvaltool/tests/unit/test_provider.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f82eb5f..f67b27f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -94,6 +94,7 @@ jobs: uv run --package ref pytest packages/ref -r a -v --doctest-modules --cov=packages/ref/src --cov-report=term uv run --package ref-core pytest packages/ref-core -r a -v --doctest-modules --cov=packages/ref-core/src --cov-report=term --cov-append uv run --package ref-metrics-example pytest packages/ref-metrics-example -r a -v --doctest-modules --cov=packages/ref-metrics-example/src --cov-report=term --cov-append + uv run --package ref-metrics-esmvaltool pytest packages/ref-metrics-esmvaltool -r a -v --doctest-modules --cov=packages/ref-metrics-esmvaltool/src --cov-report=term --cov-append uv run coverage xml # Run integration tests (without adding to the coverage) uv run pytest tests -r a -v diff --git a/Makefile b/Makefile index 0a1c023..be3f917 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,7 @@ mypy: ## run mypy on the codebase MYPYPATH=stubs uv run --package ref-core mypy packages/ref-core MYPYPATH=stubs uv run --package ref mypy packages/ref MYPYPATH=stubs uv run --package ref-metrics-example mypy packages/ref-metrics-example + MYPYPATH=stubs uv run --package ref-metrics-esmvaltool mypy packages/ref-metrics-esmvaltool .PHONY: ruff-fixes ruff-fixes: ## fix the code using ruff @@ -56,6 +57,12 @@ test-metrics-example: ## run the tests pytest packages/ref-metrics-example \ -r a -v --doctest-modules --cov=packages/ref-metrics-example/src +.PHONY: test-metrics-esmvaltool +test-metrics-esmvaltool: ## run the tests + uv run --package ref-metrics-esmvaltool \ + pytest packages/ref-metrics-esmvaltool \ + -r a -v --doctest-modules --cov=packages/ref-metrics-esmvaltool/src + .PHONY: test-integration test-integration: ## run the integration tests uv run \ @@ -63,7 +70,7 @@ test-integration: ## run the integration tests -r a -v .PHONY: test -test: test-core test-ref test-metrics-example test-integration ## run the tests +test: test-core test-ref test-metrics-example test-metrics-esmvaltool test-integration ## run the tests # Note on code coverage and testing: # If you want to debug what is going on with coverage, we have found diff --git a/changelog/40.feature.md b/changelog/40.feature.md new file mode 100644 index 0000000..7f477f4 --- /dev/null +++ b/changelog/40.feature.md @@ -0,0 +1 @@ +Added an example ESMValTool metric. diff --git a/docs/gen_doc_stubs.py b/docs/gen_doc_stubs.py index 7f6988e..55a92ea 100644 --- a/docs/gen_doc_stubs.py +++ b/docs/gen_doc_stubs.py @@ -130,6 +130,7 @@ def write_module_page( write_module_page("ref") write_module_page("ref_core") write_module_page("ref_metrics_example") +write_module_page("ref_metrics_esmvaltool") with mkdocs_gen_files.open(ROOT_DIR / "NAVIGATION.md", "w") as fh: fh.writelines(nav.build_literate_nav()) diff --git a/packages/ref-metrics-esmvaltool/README.md b/packages/ref-metrics-esmvaltool/README.md new file mode 100644 index 0000000..34e7f1d --- /dev/null +++ b/packages/ref-metrics-esmvaltool/README.md @@ -0,0 +1,8 @@ +# ref-metrics-esmvaltool + +Use [ESMValTool](https://esmvaltool.org/) as a REF metrics provider. + +To use this, install ESMValTool and then install the REF into the same conda +environment. + +See [running-metrics-locally](https://cmip-ref.readthedocs.io/en/latest/how-to-guides/running-metrics-locally/) for usage instructions. diff --git a/packages/ref-metrics-esmvaltool/pyproject.toml b/packages/ref-metrics-esmvaltool/pyproject.toml new file mode 100644 index 0000000..def11d4 --- /dev/null +++ b/packages/ref-metrics-esmvaltool/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "ref-metrics-esmvaltool" +version = "0.1.0" +description = "ESMValTool metrics provider for the CMIP Rapid Evaluation Framework" +readme = "README.md" +authors = [ + { name = "ESMValTool development team", email = "esmvaltool-dev@listserv.dfn.de " } +] +requires-python = ">=3.10" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "Intended Audience :: Science/Research", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering", +] +dependencies = [ + "pooch >= 1.8", + "ref-core", + "ruamel.yaml >= 0.18", + "xarray >= 2022", +] + +[project.license] +text = "Apache-2.0" + +[tool.uv] +dev-dependencies = [ + "pytest-mock >= 3.12", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/__init__.py b/packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/__init__.py new file mode 100644 index 0000000..6d56daf --- /dev/null +++ b/packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/__init__.py @@ -0,0 +1,16 @@ +""" +Rapid evaluating CMIP data with ESMValTool. +""" + +import importlib.metadata + +from ref_core.providers import MetricsProvider + +from ref_metrics_esmvaltool.example import GlobalMeanTimeseries + +__version__ = importlib.metadata.version("ref_metrics_esmvaltool") +__core_version__ = importlib.metadata.version("ref_core") + +# Initialise the metrics manager and register the example metric +provider = MetricsProvider("ESMValTool", __version__) +provider.register(GlobalMeanTimeseries()) diff --git a/packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/example.py b/packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/example.py new file mode 100644 index 0000000..18a0a8e --- /dev/null +++ b/packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/example.py @@ -0,0 +1,108 @@ +from typing import Any + +import xarray +from ref_core.datasets import FacetFilter, SourceDatasetType +from ref_core.metrics import DataRequirement, Metric, MetricExecutionDefinition, MetricResult +from ruamel.yaml import YAML + +from ref_metrics_esmvaltool.recipe import dataframe_to_recipe, load_recipe, run_recipe + +yaml = YAML() + + +def format_cmec_output_bundle(dataset: xarray.Dataset) -> dict[str, Any]: + """ + Create a simple CMEC output bundle for the dataset. + + Parameters + ---------- + dataset + Processed dataset + + Returns + ------- + A CMEC output bundle ready to be written to disk + """ + # TODO: Check how timeseries data are generally serialised + cmec_output = { + "DIMENSIONS": { + "dimensions": { + "source_id": {dataset.attrs["source_id"]: {}}, + "region": {"global": {}}, + "variable": {"tas": {}}, + }, + "json_structure": [ + "model", + "region", + "statistic", + ], + }, + # Is the schema tracked? + "SCHEMA": { + "name": "CMEC-REF", + "package": "example", + "version": "v1", + }, + "RESULTS": { + dataset.attrs["source_id"]: {"global": {"tas": 0}}, + }, + } + + return cmec_output + + +class GlobalMeanTimeseries(Metric): + """ + Calculate the annual mean global mean timeseries for a dataset + """ + + name = "Global Mean Timeseries" + slug = "esmvaltool-global-mean-timeseries" + + data_requirements = ( + DataRequirement( + source_type=SourceDatasetType.CMIP6, + filters=(FacetFilter(facets={"variable_id": ("tas",)}),), + # Add cell areas to the groups + # constraints=(AddCellAreas(),), + # Run the metric on each unique combination of model, variable, experiment, and variant + group_by=("source_id", "variable_id", "experiment_id", "variant_label"), + ), + ) + + def run(self, definition: MetricExecutionDefinition) -> MetricResult: + """ + Run a metric + + Parameters + ---------- + definition + A description of the information needed for this execution of the metric + + Returns + ------- + : + The result of running the metric. + """ + # Load recipe and clear unwanted elements + recipe = load_recipe("examples/recipe_python.yml") + recipe["datasets"].clear() + recipe["diagnostics"].pop("map") + variables = recipe["diagnostics"]["timeseries"]["variables"] + variables.clear() + + # Prepare updated variables section in recipe. + recipe_variables = dataframe_to_recipe(definition.metric_dataset[SourceDatasetType.CMIP6].datasets) + for variable in recipe_variables.values(): + variable["preprocessor"] = "annual_mean_global" + variable["caption"] = "Annual global mean {long_name} according to {dataset}." + + # Populate recipe with new variables/datasets. + variables.update(recipe_variables) + + # Run recipe + result_dir = run_recipe(recipe, definition) + result = next(result_dir.glob("work/timeseries/script1/*.nc")) + annual_mean_global_mean_timeseries = xarray.open_dataset(result) + + return MetricResult.build(definition, format_cmec_output_bundle(annual_mean_global_mean_timeseries)) diff --git a/packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/py.typed b/packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/recipe.py b/packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/recipe.py new file mode 100644 index 0000000..37ad5f5 --- /dev/null +++ b/packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/recipe.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import importlib.resources +import subprocess +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pooch # type: ignore[import-untyped] +from ref_core.datasets import SourceDatasetType +from ref_core.metrics import MetricExecutionDefinition +from ruamel.yaml import YAML + +if TYPE_CHECKING: + import pandas as pd + +yaml = YAML() + +FACETS = { + "CMIP6": { + "dataset": "source_id", + "ensemble": "member_id", + "exp": "experiment_id", + "grid": "grid_label", + "mip": "table_id", + "short_name": "variable_id", + }, +} + + +def as_isodate(timestamp: pd.Timestamp) -> str: + """Format a timestamp as an ISO 8601 datetime. + + For example, '2014-12-16 12:00:00' will be formatted as '20141216T120000'. + + Parameters + ---------- + timestamp + The timestamp to format. + + """ + return str(timestamp).replace(" ", "T").replace("-", "").replace(":", "") + + +def as_timerange(group: pd.DataFrame) -> str | None: + """Format the timeranges from a dataframe as an ESMValTool timerange. + + Parameters + ---------- + group + The dataframe describing a single dataset. + + Returns + ------- + A timerange. + """ + start_times = group.start_time.dropna() + if start_times.empty: + return None + end_times = group.end_time.dropna() + if end_times.empty: + return None + return f"{as_isodate(start_times.min())}/{as_isodate(end_times.max())}" + + +def as_facets( + group: pd.DataFrame, +) -> dict[str, Any]: + """Convert a group from the datasets dataframe to ESMValTool facets. + + Parameters + ---------- + group: + A group of datasets representing a single instance_id. + + Returns + ------- + A :obj:`dict` containing facet-value pairs. + + """ + facets = {} + first_row = group.iloc[0] + project = first_row.instance_id.split(".", 2)[0] + facets["project"] = project + for esmvaltool_name, ref_name in FACETS[project].items(): + facets[esmvaltool_name] = getattr(first_row, ref_name) + timerange = as_timerange(group) + if timerange is not None: + facets["timerange"] = timerange + return facets + + +def dataframe_to_recipe(datasets: pd.DataFrame) -> dict[str, Any]: + """Convert the datasets dataframe to a recipe "variables" section. + + Parameters + ---------- + datasets + The pandas dataframe describing the input datasets. + + Returns + ------- + A "variables" section that can be used in an ESMValTool recipe. + """ + variables: dict[str, Any] = {} + # TODO: refine to make it possible to combine historical and scenario runs. + for _, group in datasets.groupby("instance_id"): + facets = as_facets(group) + short_name = facets.pop("short_name") + if short_name not in variables: + variables[short_name] = {"additional_datasets": []} + variables[short_name]["additional_datasets"].append(facets) + return variables + + +_ESMVALTOOL_VERSION = "2.11.0" + +_RECIPES = pooch.create( + path=pooch.os_cache("ref_metrics_esmvaltool"), + base_url="https://raw.githubusercontent.com/ESMValGroup/ESMValTool/refs/tags/v{version}/esmvaltool/recipes/", + version=_ESMVALTOOL_VERSION, + env="REF_METRICS_ESMVALTOOL_DATA_DIR", +) +with importlib.resources.files("ref_metrics_esmvaltool").joinpath("recipes.txt").open("rb") as _file: + _RECIPES.load_registry(_file) + + +def load_recipe(recipe: str) -> dict[str, Any]: + """Load a recipe. + + Parameters + ---------- + recipe + The name of an ESMValTool recipe. + + Returns + ------- + The loaded recipe. + """ + filename = _RECIPES.fetch(recipe) + return yaml.load(Path(filename).read_text(encoding="utf-8")) # type: ignore[no-any-return] + + +def prepare_climate_data(datasets: pd.DataFrame, climate_data_dir: Path) -> None: + """Symlink the input files from the Pandas dataframe into a directory tree. + + This ensures that ESMValTool can find the data and only uses the + requested data. + + Parameters + ---------- + datasets + The pandas dataframe describing the input datasets. + climate_data_dir + The directory where ESMValTool should look for input data. + """ + for row in datasets.itertuples(): + if not isinstance(row.instance_id, str): + msg = f"Invalid instance_id encountered in {row}" + raise ValueError(msg) + if not isinstance(row.path, str): + msg = f"Invalid path encountered in {row}" + raise ValueError(msg) + tgt = climate_data_dir.joinpath(*row.instance_id.split(".")) / Path(row.path).name + tgt.parent.mkdir(parents=True, exist_ok=True) + tgt.symlink_to(row.path) + + +def run_recipe(recipe: dict[str, Any], definition: MetricExecutionDefinition) -> Path: + """Run an ESMValTool recipe. + + Parameters + ---------- + recipe + The ESMValTool recipe. + definition + A description of the information needed for this execution of the metric. + + """ + output_dir = definition.output_fragment + + recipe_path = output_dir / "recipe_example.yml" + with recipe_path.open("w", encoding="utf-8") as file: + yaml.dump(recipe, file) + + climate_data = output_dir / "climate_data" + + prepare_climate_data( + definition.metric_dataset[SourceDatasetType.CMIP6].datasets, + climate_data_dir=climate_data, + ) + + results_dir = output_dir / "results" + config = { + "drs": { + "CMIP6": "ESGF", + }, + "output_dir": str(results_dir), + "rootpath": { + "default": str(climate_data), + }, + "search_esgf": "never", + } + config_dir = output_dir / "config" + config_dir.mkdir() + with (config_dir / "config.yml").open("w", encoding="utf-8") as file: + yaml.dump(config, file) + + subprocess.check_call(["esmvaltool", "run", f"--config-dir={config_dir}", f"{recipe_path}"]) # noqa: S603, S607 + result = next(results_dir.glob("*")) + return result diff --git a/packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/recipes.txt b/packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/recipes.txt new file mode 100644 index 0000000..66600a8 --- /dev/null +++ b/packages/ref-metrics-esmvaltool/src/ref_metrics_esmvaltool/recipes.txt @@ -0,0 +1 @@ +examples/recipe_python.yml ab3f06d269bb2c1368f4dc39da9bcb232fb2adb1fa556ba769e6c16294ffb4a3 diff --git a/packages/ref-metrics-esmvaltool/tests/unit/test_metrics.py b/packages/ref-metrics-esmvaltool/tests/unit/test_metrics.py new file mode 100644 index 0000000..3b8b93d --- /dev/null +++ b/packages/ref-metrics-esmvaltool/tests/unit/test_metrics.py @@ -0,0 +1,64 @@ +import pytest +import ref_metrics_esmvaltool +from ref_core.datasets import DatasetCollection, MetricDataset, SourceDatasetType +from ref_core.metrics import MetricExecutionDefinition +from ref_metrics_esmvaltool.example import GlobalMeanTimeseries + + +@pytest.fixture +def metric_dataset(cmip6_data_catalog) -> MetricDataset: + selected_dataset = cmip6_data_catalog[ + cmip6_data_catalog["instance_id"] == cmip6_data_catalog.instance_id.iloc[0] + ] + return MetricDataset( + { + SourceDatasetType.CMIP6: DatasetCollection( + selected_dataset, + "instance_id", + ) + } + ) + + +def test_example_metric(tmp_path, mocker, metric_dataset, cmip6_data_catalog): + metric = GlobalMeanTimeseries() + ds = cmip6_data_catalog.groupby("instance_id", as_index=False).first() + + configuration = MetricExecutionDefinition( + output_fragment=tmp_path, + key="global_mean_timeseries", + metric_dataset=MetricDataset( + { + SourceDatasetType.CMIP6: DatasetCollection(ds, "instance_id"), + } + ), + ) + + result_dir = configuration.output_fragment / "results" / "recipe_test_a" + result = result_dir / "work" / "timeseries" / "script1" / "result.nc" + + def mock_check_call(cmd, *args, **kwargs): + result.parent.mkdir(parents=True) + result.touch() + + mocker.patch.object( + ref_metrics_esmvaltool.recipe.subprocess, + "check_call", + autospec=True, + spec_set=True, + side_effect=mock_check_call, + ) + open_dataset = mocker.patch.object( + ref_metrics_esmvaltool.example.xarray, + "open_dataset", + autospec=True, + spec_set=True, + ) + open_dataset.return_value.attrs.__getitem__.return_value = "ABC" + + result = metric.run(configuration) + + assert result.successful + assert result.output_bundle.exists() + assert result.output_bundle.is_file() + assert result.output_bundle.name == "output.json" diff --git a/packages/ref-metrics-esmvaltool/tests/unit/test_provider.py b/packages/ref-metrics-esmvaltool/tests/unit/test_provider.py new file mode 100644 index 0000000..d777576 --- /dev/null +++ b/packages/ref-metrics-esmvaltool/tests/unit/test_provider.py @@ -0,0 +1,15 @@ +from ref_metrics_esmvaltool import __core_version__, __version__, provider + + +# Placeholder to get CI working +def test_version(): + assert __version__ == "0.1.0" + assert __core_version__ == "0.1.0" + + +def test_provider(): + assert provider.name == "ESMValTool" + assert provider.slug == "esmvaltool" + assert provider.version == __version__ + + assert len(provider) == 1 diff --git a/packages/ref/src/ref/provider_registry.py b/packages/ref/src/ref/provider_registry.py index 18b8904..e5cc829 100644 --- a/packages/ref/src/ref/provider_registry.py +++ b/packages/ref/src/ref/provider_registry.py @@ -81,8 +81,10 @@ def build_from_db(db: Database) -> "ProviderRegistry": A new ProviderRegistry instance """ # TODO: We don't yet have any tables to represent metrics providers - from ref_metrics_example import provider + from ref_metrics_esmvaltool import provider as esmvaltool_provider + from ref_metrics_example import provider as example_provider with db.session.begin_nested(): - _register_provider(db, provider) - return ProviderRegistry(providers=[provider]) + _register_provider(db, example_provider) + _register_provider(db, esmvaltool_provider) + return ProviderRegistry(providers=[example_provider, esmvaltool_provider]) diff --git a/packages/ref/tests/unit/cli/test_datasets.py b/packages/ref/tests/unit/cli/test_datasets.py index 6c58bef..68ec38f 100644 --- a/packages/ref/tests/unit/cli/test_datasets.py +++ b/packages/ref/tests/unit/cli/test_datasets.py @@ -65,6 +65,7 @@ def test_ingest_and_solve(self, esgf_data_dir, db, invoke_cli): "--source-type", "cmip6", "--solve", + "--dry-run", ], ) assert "Solving for metrics that require recalculation." in result.stderr diff --git a/packages/ref/tests/unit/test_solver.py b/packages/ref/tests/unit/test_solver.py index 3e2aa2f..3999c9d 100644 --- a/packages/ref/tests/unit/test_solver.py +++ b/packages/ref/tests/unit/test_solver.py @@ -179,7 +179,7 @@ def test_solve_metrics_default_solver(mock_executor, db_seeded, solver): with db_seeded.session.begin(): solve_metrics(db_seeded) - assert mock_executor.return_value.run_metric.call_count == 2 + assert mock_executor.return_value.run_metric.call_count == 3 @mock.patch("ref.solver.get_executor") diff --git a/pyproject.toml b/pyproject.toml index 8ea7688..1ac75b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "ref[postgres]", "ref-core", "ref-metrics-example", + "ref-metrics-esmvaltool", ] [project.license] @@ -59,6 +60,7 @@ members = ["packages/*"] ref = { workspace = true } ref-core = { workspace = true } ref-metrics-example = { workspace = true } +ref-metrics-esmvaltool = { workspace = true } [tool.coverage.run] source = ["packages"] diff --git a/tests/integration/test_solve.py b/tests/integration/test_solve.py index f824152..d268951 100644 --- a/tests/integration/test_solve.py +++ b/tests/integration/test_solve.py @@ -1,10 +1,35 @@ +import ref.solver from ref.database import Database from ref.models import Dataset, MetricExecution +from ref.provider_registry import ProviderRegistry, _register_provider -def test_solve(esgf_data_dir, config, invoke_cli): - db = Database.from_config(config) +class ExampleProviderRegistry(ProviderRegistry): + def build_from_db(db: Database) -> "ExampleProviderRegistry": + """ + Create a ProviderRegistry instance containing only the Example provider. + + Parameters + ---------- + db + Database instance + + Returns + ------- + : + A new ProviderRegistry instance + """ + # TODO: We don't yet have any tables to represent metrics providers + from ref_metrics_example import provider as example_provider + with db.session.begin_nested(): + _register_provider(db, example_provider) + return ProviderRegistry(providers=[example_provider]) + + +def test_solve(esgf_data_dir, config, invoke_cli, monkeypatch): + db = Database.from_config(config) + monkeypatch.setattr(ref.solver, "ProviderRegistry", ExampleProviderRegistry) invoke_cli(["datasets", "ingest", "--source-type", "cmip6", str(esgf_data_dir)]) assert db.session.query(Dataset).count() == 5 diff --git a/uv.lock b/uv.lock index 2bb35cd..4cd4d75 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ members = [ "cmip-ref", "ref", "ref-core", + "ref-metrics-esmvaltool", "ref-metrics-example", ] @@ -500,6 +501,7 @@ source = { virtual = "." } dependencies = [ { name = "ref", extra = ["postgres"] }, { name = "ref-core" }, + { name = "ref-metrics-esmvaltool" }, { name = "ref-metrics-example" }, ] @@ -538,6 +540,7 @@ dev = [ requires-dist = [ { name = "ref", extras = ["postgres"], editable = "packages/ref" }, { name = "ref-core", editable = "packages/ref-core" }, + { name = "ref-metrics-esmvaltool", editable = "packages/ref-metrics-esmvaltool" }, { name = "ref-metrics-example", editable = "packages/ref-metrics-example" }, ] @@ -2592,6 +2595,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, ] +[[package]] +name = "pooch" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "platformdirs" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/77/b3d3e00c696c16cf99af81ef7b1f5fe73bd2a307abca41bd7605429fe6e5/pooch-1.8.2.tar.gz", hash = "sha256:76561f0de68a01da4df6af38e9955c4c9d1a5c90da73f7e40276a5728ec83d10", size = 59353 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/87/77cc11c7a9ea9fd05503def69e3d18605852cd0d4b0d3b8f15bbeb3ef1d1/pooch-1.8.2-py3-none-any.whl", hash = "sha256:3529a57096f7198778a5ceefd5ac3ef0e4d06a6ddaf9fc2d609b806f25302c47", size = 64574 }, +] + [[package]] name = "pre-commit" version = "4.0.1" @@ -2958,6 +2975,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/ef/b0c2e96e3508bca8d1874e39789d541cd7f4731b38bcf9c7098f0b882001/pytest_loguru-0.4.0-py3-none-any.whl", hash = "sha256:3cc7b9c6b22cb158209ccbabf0d678dacd3f3c7497d6f46f1c338c13bee1ac77", size = 3886 }, ] +[[package]] +name = "pytest-mock" +version = "3.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/90/a955c3ab35ccd41ad4de556596fa86685bf4fc5ffcc62d22d856cfd4e29a/pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0", size = 32814 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, +] + [[package]] name = "pytest-regressions" version = "2.5.0" @@ -3230,6 +3259,33 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "attrs", specifier = ">=22.1.0" }] +[[package]] +name = "ref-metrics-esmvaltool" +version = "0.1.0" +source = { editable = "packages/ref-metrics-esmvaltool" } +dependencies = [ + { name = "pooch" }, + { name = "ref-core" }, + { name = "ruamel-yaml" }, + { name = "xarray" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "pooch", specifier = ">=1.8" }, + { name = "ref-core", editable = "packages/ref-core" }, + { name = "ruamel-yaml", specifier = ">=0.18" }, + { name = "xarray", specifier = ">=2022" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest-mock", specifier = ">=3.12" }] + [[package]] name = "ref-metrics-example" version = "0.1.0" @@ -3467,6 +3523,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/ea/6f121d1802f3adae1981aea4209ea66f9d3c7f2f6d6b85ef4f13a61d17ef/rpds_py-0.20.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:bb273176be34a746bdac0b0d7e4e2c467323d13640b736c4c477881a3220a989", size = 213529 }, ] +[[package]] +name = "ruamel-yaml" +version = "0.18.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ruamel-yaml-clib", marker = "python_full_version < '3.13' and platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/81/4dfc17eb6ebb1aac314a3eb863c1325b907863a1b8b1382cdffcb6ac0ed9/ruamel.yaml-0.18.6.tar.gz", hash = "sha256:8b27e6a217e786c6fbe5634d8f3f11bc63e0f80f6a5890f28863d9c45aac311b", size = 143362 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/67/8ece580cc363331d9a53055130f86b096bf16e38156e33b1d3014fffda6b/ruamel.yaml-0.18.6-py3-none-any.whl", hash = "sha256:57b53ba33def16c4f3d807c0ccbc00f8a6081827e81ba2491691b76882d0c636", size = 117761 }, +] + +[[package]] +name = "ruamel-yaml-clib" +version = "0.2.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/84/80203abff8ea4993a87d823a5f632e4d92831ef75d404c9fc78d0176d2b5/ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f", size = 225315 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/57/40a958e863e299f0c74ef32a3bde9f2d1ea8d69669368c0c502a0997f57f/ruamel.yaml.clib-0.2.12-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:11f891336688faf5156a36293a9c362bdc7c88f03a8a027c2c1d8e0bcde998e5", size = 131301 }, + { url = "https://files.pythonhosted.org/packages/98/a8/29a3eb437b12b95f50a6bcc3d7d7214301c6c529d8fdc227247fa84162b5/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a606ef75a60ecf3d924613892cc603b154178ee25abb3055db5062da811fd969", size = 633728 }, + { url = "https://files.pythonhosted.org/packages/35/6d/ae05a87a3ad540259c3ad88d71275cbd1c0f2d30ae04c65dcbfb6dcd4b9f/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd5415dded15c3822597455bc02bcd66e81ef8b7a48cb71a33628fc9fdde39df", size = 722230 }, + { url = "https://files.pythonhosted.org/packages/7f/b7/20c6f3c0b656fe609675d69bc135c03aac9e3865912444be6339207b6648/ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76", size = 686712 }, + { url = "https://files.pythonhosted.org/packages/cd/11/d12dbf683471f888d354dac59593873c2b45feb193c5e3e0f2ebf85e68b9/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6", size = 663936 }, + { url = "https://files.pythonhosted.org/packages/72/14/4c268f5077db5c83f743ee1daeb236269fa8577133a5cfa49f8b382baf13/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd", size = 696580 }, + { url = "https://files.pythonhosted.org/packages/30/fc/8cd12f189c6405a4c1cf37bd633aa740a9538c8e40497c231072d0fef5cf/ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a", size = 663393 }, + { url = "https://files.pythonhosted.org/packages/80/29/c0a017b704aaf3cbf704989785cd9c5d5b8ccec2dae6ac0c53833c84e677/ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da", size = 100326 }, + { url = "https://files.pythonhosted.org/packages/3a/65/fa39d74db4e2d0cd252355732d966a460a41cd01c6353b820a0952432839/ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28", size = 118079 }, + { url = "https://files.pythonhosted.org/packages/fb/8f/683c6ad562f558cbc4f7c029abcd9599148c51c54b5ef0f24f2638da9fbb/ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6", size = 132224 }, + { url = "https://files.pythonhosted.org/packages/3c/d2/b79b7d695e2f21da020bd44c782490578f300dd44f0a4c57a92575758a76/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d84318609196d6bd6da0edfa25cedfbabd8dbde5140a0a23af29ad4b8f91fb1e", size = 641480 }, + { url = "https://files.pythonhosted.org/packages/68/6e/264c50ce2a31473a9fdbf4fa66ca9b2b17c7455b31ef585462343818bd6c/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb43a269eb827806502c7c8efb7ae7e9e9d0573257a46e8e952f4d4caba4f31e", size = 739068 }, + { url = "https://files.pythonhosted.org/packages/86/29/88c2567bc893c84d88b4c48027367c3562ae69121d568e8a3f3a8d363f4d/ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52", size = 703012 }, + { url = "https://files.pythonhosted.org/packages/11/46/879763c619b5470820f0cd6ca97d134771e502776bc2b844d2adb6e37753/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642", size = 704352 }, + { url = "https://files.pythonhosted.org/packages/02/80/ece7e6034256a4186bbe50dee28cd032d816974941a6abf6a9d65e4228a7/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2", size = 737344 }, + { url = "https://files.pythonhosted.org/packages/f0/ca/e4106ac7e80efbabdf4bf91d3d32fc424e41418458251712f5672eada9ce/ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3", size = 714498 }, + { url = "https://files.pythonhosted.org/packages/67/58/b1f60a1d591b771298ffa0428237afb092c7f29ae23bad93420b1eb10703/ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4", size = 100205 }, + { url = "https://files.pythonhosted.org/packages/b4/4f/b52f634c9548a9291a70dfce26ca7ebce388235c93588a1068028ea23fcc/ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb", size = 118185 }, + { url = "https://files.pythonhosted.org/packages/48/41/e7a405afbdc26af961678474a55373e1b323605a4f5e2ddd4a80ea80f628/ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632", size = 133433 }, + { url = "https://files.pythonhosted.org/packages/ec/b0/b850385604334c2ce90e3ee1013bd911aedf058a934905863a6ea95e9eb4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d", size = 647362 }, + { url = "https://files.pythonhosted.org/packages/44/d0/3f68a86e006448fb6c005aee66565b9eb89014a70c491d70c08de597f8e4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c", size = 754118 }, + { url = "https://files.pythonhosted.org/packages/52/a9/d39f3c5ada0a3bb2870d7db41901125dbe2434fa4f12ca8c5b83a42d7c53/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd", size = 706497 }, + { url = "https://files.pythonhosted.org/packages/b0/fa/097e38135dadd9ac25aecf2a54be17ddf6e4c23e43d538492a90ab3d71c6/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31", size = 698042 }, + { url = "https://files.pythonhosted.org/packages/ec/d5/a659ca6f503b9379b930f13bc6b130c9f176469b73b9834296822a83a132/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680", size = 745831 }, + { url = "https://files.pythonhosted.org/packages/db/5d/36619b61ffa2429eeaefaab4f3374666adf36ad8ac6330d855848d7d36fd/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d", size = 715692 }, + { url = "https://files.pythonhosted.org/packages/b1/82/85cb92f15a4231c89b95dfe08b09eb6adca929ef7df7e17ab59902b6f589/ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5", size = 98777 }, + { url = "https://files.pythonhosted.org/packages/d7/8f/c3654f6f1ddb75daf3922c3d8fc6005b1ab56671ad56ffb874d908bfa668/ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4", size = 115523 }, + { url = "https://files.pythonhosted.org/packages/29/00/4864119668d71a5fa45678f380b5923ff410701565821925c69780356ffa/ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a", size = 132011 }, + { url = "https://files.pythonhosted.org/packages/7f/5e/212f473a93ae78c669ffa0cb051e3fee1139cb2d385d2ae1653d64281507/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475", size = 642488 }, + { url = "https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef", size = 745066 }, + { url = "https://files.pythonhosted.org/packages/e2/a9/28f60726d29dfc01b8decdb385de4ced2ced9faeb37a847bd5cf26836815/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6", size = 701785 }, + { url = "https://files.pythonhosted.org/packages/84/7e/8e7ec45920daa7f76046578e4f677a3215fe8f18ee30a9cb7627a19d9b4c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf", size = 693017 }, + { url = "https://files.pythonhosted.org/packages/c5/b3/d650eaade4ca225f02a648321e1ab835b9d361c60d51150bac49063b83fa/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1", size = 741270 }, + { url = "https://files.pythonhosted.org/packages/87/b8/01c29b924dcbbed75cc45b30c30d565d763b9c4d540545a0eeecffb8f09c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01", size = 709059 }, + { url = "https://files.pythonhosted.org/packages/30/8c/ed73f047a73638257aa9377ad356bea4d96125b305c34a28766f4445cc0f/ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6", size = 98583 }, + { url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190 }, +] + [[package]] name = "ruff" version = "0.6.9"