climate-ref 0.8.1__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. {climate_ref-0.8.1 → climate_ref-0.9.0}/.gitignore +10 -1
  2. {climate_ref-0.8.1 → climate_ref-0.9.0}/Dockerfile +20 -11
  3. {climate_ref-0.8.1 → climate_ref-0.9.0}/PKG-INFO +2 -1
  4. {climate_ref-0.8.1 → climate_ref-0.9.0}/conftest.py +10 -3
  5. {climate_ref-0.8.1 → climate_ref-0.9.0}/pyproject.toml +2 -1
  6. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/cli/__init__.py +5 -2
  7. climate_ref-0.9.0/src/climate_ref/cli/_git_utils.py +112 -0
  8. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/cli/_utils.py +24 -0
  9. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/cli/datasets.py +1 -0
  10. climate_ref-0.9.0/src/climate_ref/cli/providers.py +183 -0
  11. climate_ref-0.9.0/src/climate_ref/cli/test_cases.py +729 -0
  12. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/config.py +1 -1
  13. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/database.py +23 -0
  14. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/datasets/__init__.py +15 -11
  15. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/datasets/base.py +11 -17
  16. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/datasets/cmip6.py +1 -1
  17. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/solver.py +1 -1
  18. climate_ref-0.9.0/src/climate_ref/testing.py +218 -0
  19. climate_ref-0.9.0/tests/integration/test_provider_setup.py +56 -0
  20. climate_ref-0.9.0/tests/unit/cli/test_git_utils.py +219 -0
  21. climate_ref-0.9.0/tests/unit/cli/test_providers.py +69 -0
  22. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/cli/test_root.py +1 -1
  23. climate_ref-0.9.0/tests/unit/cli/test_test_cases.py +723 -0
  24. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/cli/test_utils.py +42 -1
  25. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/datasets/test_cmip6.py +37 -29
  26. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/datasets/test_datasets.py +2 -1
  27. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/executor/test_result_handling.py +2 -0
  28. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/test_config.py +16 -0
  29. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/test_database.py +14 -13
  30. climate_ref-0.9.0/tests/unit/test_testing.py +174 -0
  31. climate_ref-0.8.1/src/climate_ref/cli/providers.py +0 -84
  32. climate_ref-0.8.1/src/climate_ref/testing.py +0 -116
  33. climate_ref-0.8.1/tests/unit/cli/test_providers.py +0 -23
  34. {climate_ref-0.8.1 → climate_ref-0.9.0}/LICENCE +0 -0
  35. {climate_ref-0.8.1 → climate_ref-0.9.0}/NOTICE +0 -0
  36. {climate_ref-0.8.1 → climate_ref-0.9.0}/README.md +0 -0
  37. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/__init__.py +0 -0
  38. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/_config_helpers.py +0 -0
  39. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/alembic.ini +0 -0
  40. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/cli/config.py +0 -0
  41. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/cli/executions.py +0 -0
  42. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/cli/solve.py +0 -0
  43. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/constants.py +0 -0
  44. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/dataset_registry/obs4ref_reference.txt +0 -0
  45. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/dataset_registry/sample_data.txt +0 -0
  46. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/datasets/cmip6_parsers.py +0 -0
  47. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/datasets/obs4mips.py +0 -0
  48. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/datasets/pmp_climatology.py +0 -0
  49. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/datasets/utils.py +0 -0
  50. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/executor/__init__.py +0 -0
  51. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/executor/hpc.py +0 -0
  52. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/executor/local.py +0 -0
  53. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/executor/pbs_scheduler.py +0 -0
  54. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/executor/result_handling.py +0 -0
  55. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/executor/synchronous.py +0 -0
  56. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/migrations/README +0 -0
  57. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/migrations/env.py +0 -0
  58. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/migrations/script.py.mako +0 -0
  59. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/migrations/versions/2025-05-02T1418_341a4aa2551e_regenerate.py +0 -0
  60. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/migrations/versions/2025-05-09T2032_03dbb4998e49_series_metric_value.py +0 -0
  61. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/migrations/versions/2025-07-03T1505_795c1e6cf496_drop_unique_requirement_on_slug.py +0 -0
  62. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/migrations/versions/2025-07-20T1521_94beace57a9c_cmip6_finalised.py +0 -0
  63. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/migrations/versions/2025-08-05T0327_a1b2c3d4e5f6_finalised_on_base_dataset.py +0 -0
  64. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/migrations/versions/2025-09-05T2019_8d28e5e0f9c3_add_indexes.py +0 -0
  65. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/migrations/versions/2025-09-10T1358_2f6e36738e06_use_version_as_version_facet_for_.py +0 -0
  66. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/migrations/versions/2025-09-22T2359_20cd136a5b04_add_pmp_version.py +0 -0
  67. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/models/__init__.py +0 -0
  68. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/models/base.py +0 -0
  69. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/models/dataset.py +0 -0
  70. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/models/diagnostic.py +0 -0
  71. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/models/execution.py +0 -0
  72. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/models/metric_value.py +0 -0
  73. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/models/mixins.py +0 -0
  74. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/models/provider.py +0 -0
  75. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/provider_registry.py +0 -0
  76. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/py.typed +0 -0
  77. {climate_ref-0.8.1 → climate_ref-0.9.0}/src/climate_ref/slurm.py +0 -0
  78. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/cli/test_config.py +0 -0
  79. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/cli/test_datasets.py +0 -0
  80. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/cli/test_executions/test_inspect.txt +0 -0
  81. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/cli/test_executions.py +0 -0
  82. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/cli/test_solve.py +0 -0
  83. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/datasets/conftest.py +0 -0
  84. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/datasets/test_cmip6/cmip6_catalog_db.yml +0 -0
  85. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/datasets/test_cmip6/cmip6_catalog_db_complete.yml +0 -0
  86. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/datasets/test_cmip6/cmip6_catalog_db_drs.yml +0 -0
  87. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/datasets/test_cmip6/cmip6_catalog_local_complete.yml +0 -0
  88. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/datasets/test_cmip6/cmip6_catalog_local_drs.yml +0 -0
  89. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/datasets/test_obs4mips/obs4mips_catalog_db.yml +0 -0
  90. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/datasets/test_obs4mips/obs4mips_catalog_local.yml +0 -0
  91. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/datasets/test_obs4mips.py +0 -0
  92. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/datasets/test_pmp_climatology/pmp_catalog_local.yml +0 -0
  93. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/datasets/test_pmp_climatology.py +0 -0
  94. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/datasets/test_utils.py +0 -0
  95. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/executor/test_hpc_executor.py +0 -0
  96. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/executor/test_local_executor.py +0 -0
  97. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/executor/test_synchronous_executor.py +0 -0
  98. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/models/test_metric_execution.py +0 -0
  99. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/models/test_metric_value.py +0 -0
  100. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/test_pbssmartprovider.py +0 -0
  101. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/test_provider_registry.py +0 -0
  102. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/test_slurm.py +0 -0
  103. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/test_solver/test_solve_metrics.yml +0 -0
  104. {climate_ref-0.8.1 → climate_ref-0.9.0}/tests/unit/test_solver.py +0 -0
@@ -150,7 +150,7 @@ dmypy.json
150
150
 
151
151
  # Generated output
152
152
  out
153
- .ref
153
+ .ref*
154
154
 
155
155
  # Ignore copied LICENCE/NOTICE files
156
156
  packages/*/LICENCE
@@ -158,3 +158,12 @@ packages/*/NOTICE
158
158
 
159
159
  # Local directory for data
160
160
  /data
161
+
162
+ # Generated SDK
163
+ /climate_ref_client
164
+
165
+ # User-specific catalog paths (test data)
166
+ *.paths.yaml
167
+
168
+ # Helm dependencies
169
+ helm/charts/*
@@ -2,7 +2,7 @@
2
2
  # This docker container packages up the REF cli tool with the default set of diagnostic providers
3
3
  # used as part of the CMIP7 FastTrack process.
4
4
 
5
- FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS build
5
+ FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS base
6
6
 
7
7
  RUN apt-get update && apt-get install -y --no-install-recommends \
8
8
  git \
@@ -16,6 +16,7 @@ ENV UV_LINK_MODE=copy
16
16
 
17
17
  WORKDIR /app
18
18
 
19
+ FROM base AS build
19
20
  # Install the project's dependencies using the lockfile and settings
20
21
  RUN --mount=type=cache,target=/root/.cache/uv \
21
22
  --mount=type=bind,source=uv.lock,target=uv.lock \
@@ -24,22 +25,18 @@ RUN --mount=type=cache,target=/root/.cache/uv \
24
25
 
25
26
  # Then, add the rest of the project source code and install it
26
27
  # Installing separately from its dependencies allows optimal layer caching
27
- ADD . /app
28
+ COPY . /app
28
29
 
29
30
  # Sync the project as non-editable installs
30
31
  RUN --mount=type=cache,target=/root/.cache/uv \
31
32
  uv sync --frozen --no-editable --no-dev
32
33
 
33
34
  # Runtime container
34
- FROM python:3.11-slim AS runtime
35
+ FROM base AS runtime
35
36
 
36
37
  LABEL maintainer="Jared Lewis <jared.lewis@climate-resource.com>"
37
38
  LABEL description="Base Docker image for the REF compute engine"
38
39
 
39
- RUN apt-get update && apt-get install -y --no-install-recommends \
40
- git \
41
- && rm -rf /var/lib/apt/lists/*
42
-
43
40
  # Create a non-root user
44
41
  RUN useradd -m -u 1000 app
45
42
 
@@ -48,18 +45,30 @@ ENV PATH="/app/.venv/bin:${PATH}"
48
45
  ENV VIRTUAL_ENV=/app/.venv
49
46
 
50
47
  # Copy the installed packages from the build stage
51
- COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /app/.venv/bin/
52
48
  COPY --from=build --chown=app:app /app/.venv /app/.venv
53
49
  COPY --from=build --chown=app:app /app/scripts /app/scripts
54
50
 
55
51
  # Location of the REF configuration files
56
52
  ENV REF_CONFIGURATION=/ref
57
53
 
54
+ # Set matplotlib config directory to a location with write permissions
55
+ ENV MPLCONFIGDIR=$REF_CONFIGURATION/software/matplotlib
56
+
57
+ # Set micromamba root to locations with write permissions
58
+ ENV MAMBA_ROOT_PREFIX=$REF_CONFIGURATION/software/conda
59
+ ENV CONDA_PKGS_DIRS=$REF_CONFIGURATION/software/conda/pkgs
60
+ ENV XDG_CACHE_HOME=$REF_CONFIGURATION/cache
61
+
58
62
  # Create necessary directories with proper permissions
59
- RUN mkdir -p /ref /app/cache && chown -R app:app /ref /app/cache
63
+ RUN install --owner=app --group=app -d /ref /app/cache && \
64
+ install --mode=555 -d /home/app/.conda && \
65
+ touch /home/app/.conda/environments.txt # conda tries to write to ~/.conda/environments.txt despite its root being set elsewhere. See: https://github.com/conda/conda/issues/8804
66
+
67
+ # Switch to non-root user -- use numeric ID for k8s systems that enforce runAsUser
68
+ USER 1000
60
69
 
61
- # Switch to non-root user
62
- USER app
70
+ # Pre-cache matplotlib fonts and ilamb3 config
71
+ RUN /app/.venv/bin/python -c "import matplotlib; import ilamb3"
63
72
 
64
73
  # Run the REF CLI tool by default
65
74
  ENTRYPOINT ["/app/.venv/bin/ref"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: climate-ref
3
- Version: 0.8.1
3
+ Version: 0.9.0
4
4
  Summary: Application which runs the CMIP Rapid Evaluation Framework
5
5
  Author-email: Jared Lewis <jared.lewis@climate-resource.com>, Mika Pflueger <mika.pflueger@climate-resource.com>, Bouwe Andela <b.andela@esciencecenter.nl>, Jiwoo Lee <lee1043@llnl.gov>, Min Xu <xum1@ornl.gov>, Nathan Collier <collierno@ornl.gov>, Dora Hegedus <dora.hegedus@stfc.ac.uk>
6
6
  License-Expression: Apache-2.0
@@ -25,6 +25,7 @@ Requires-Dist: cattrs>=24.1.2
25
25
  Requires-Dist: climate-ref-core
26
26
  Requires-Dist: ecgtools>=2024.7.31
27
27
  Requires-Dist: environs>=11.0.0
28
+ Requires-Dist: gitpython>=3.1.0
28
29
  Requires-Dist: loguru>=0.7.2
29
30
  Requires-Dist: parsl>=2025.5.19; sys_platform != 'win32'
30
31
  Requires-Dist: platformdirs>=4.3.6
@@ -1,5 +1,6 @@
1
1
  import importlib.resources
2
2
  import shutil
3
+ from collections.abc import Generator
3
4
  from pathlib import Path
4
5
  from urllib import parse as urlparse
5
6
 
@@ -38,8 +39,10 @@ def _clone_db(target_db_url: str, template_db_path: Path) -> None:
38
39
 
39
40
 
40
41
  @pytest.fixture
41
- def db(config) -> Database:
42
- return Database.from_config(config, run_migrations=True)
42
+ def db(config) -> Generator[Database, None, None]:
43
+ database = Database.from_config(config, run_migrations=True)
44
+ yield database
45
+ database.close()
43
46
 
44
47
 
45
48
  @pytest.fixture(scope="session")
@@ -77,6 +80,8 @@ def db_seeded_template(tmp_path_session, cmip6_data_catalog, obs4mips_data_catal
77
80
  with database.session.begin():
78
81
  _register_provider(database, example_provider)
79
82
 
83
+ database.close()
84
+
80
85
  return template_db_path
81
86
 
82
87
 
@@ -85,4 +90,6 @@ def db_seeded(db_seeded_template, config) -> Database:
85
90
  # Copy the template database to the location in the config
86
91
  _clone_db(config.db.database_url, db_seeded_template)
87
92
 
88
- return Database.from_config(config, run_migrations=True)
93
+ database = Database.from_config(config, run_migrations=True)
94
+ yield database
95
+ database.close()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "climate-ref"
3
- version = "0.8.1"
3
+ version = "0.9.0"
4
4
  description = "Application which runs the CMIP Rapid Evaluation Framework"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -41,6 +41,7 @@ dependencies = [
41
41
  "ecgtools>=2024.7.31",
42
42
  "platformdirs>=4.3.6",
43
43
  "tqdm>=4.67.1",
44
+ "gitpython>=3.1.0",
44
45
  # parsl doesn't support Windows yet
45
46
  # We don't target Windows either, but this __might__ allow Windows users to install the package
46
47
  'parsl>=2025.5.19; sys_platform != "win32"'
@@ -11,7 +11,7 @@ from loguru import logger
11
11
  from rich.console import Console
12
12
 
13
13
  from climate_ref import __version__
14
- from climate_ref.cli import config, datasets, executions, providers, solve
14
+ from climate_ref.cli import config, datasets, executions, providers, solve, test_cases
15
15
  from climate_ref.config import Config
16
16
  from climate_ref.constants import CONFIG_FILENAME
17
17
  from climate_ref.database import Database
@@ -104,6 +104,7 @@ def build_app() -> typer.Typer:
104
104
  app.add_typer(datasets.app, name="datasets")
105
105
  app.add_typer(executions.app, name="executions")
106
106
  app.add_typer(providers.app, name="providers")
107
+ app.add_typer(test_cases.app, name="test-cases")
107
108
 
108
109
  try:
109
110
  celery_app = importlib.import_module("climate_ref_celery.cli").app
@@ -164,7 +165,9 @@ def main( # noqa: PLR0913
164
165
 
165
166
  logger.debug(f"Configuration loaded from: {config._config_file!s}")
166
167
 
167
- ctx.obj = CLIContext(config=config, database=Database.from_config(config), console=_create_console())
168
+ # Use ctx.with_resource to ensure the database connection is closed when the CLI exits
169
+ database = ctx.with_resource(Database.from_config(config))
170
+ ctx.obj = CLIContext(config=config, database=database, console=_create_console())
168
171
 
169
172
 
170
173
  if __name__ == "__main__":
@@ -0,0 +1,112 @@
1
+ """Git utilities for CLI commands."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from git import InvalidGitRepositoryError, Repo
7
+
8
+
9
+ def get_repo_for_path(path: Path) -> Repo | None:
10
+ """
11
+ Get the git repository containing the given path.
12
+
13
+ Parameters
14
+ ----------
15
+ path
16
+ Path to a file or directory
17
+
18
+ Returns
19
+ -------
20
+ :
21
+ The Repo object if path is within a git repository, None otherwise
22
+ """
23
+ try:
24
+ return Repo(path, search_parent_directories=True)
25
+ except InvalidGitRepositoryError:
26
+ return None
27
+
28
+
29
+ def get_git_status(file_path: Path, repo: Repo) -> str:
30
+ """
31
+ Get git status for a file using GitPython.
32
+
33
+ Parameters
34
+ ----------
35
+ file_path
36
+ Absolute path to the file
37
+ repo
38
+ GitPython Repo object
39
+
40
+ Returns
41
+ -------
42
+ :
43
+ Status string: "new", "staged", "modified", "tracked", "untracked", or "unknown"
44
+ """
45
+ try:
46
+ rel_path = str(file_path.relative_to(repo.working_dir))
47
+
48
+ # Check if untracked
49
+ if rel_path in repo.untracked_files:
50
+ return "new"
51
+
52
+ # Check staged changes (index vs HEAD)
53
+ staged_files = {item.a_path for item in repo.index.diff("HEAD")}
54
+ if rel_path in staged_files:
55
+ return "staged"
56
+
57
+ # Check unstaged changes (working tree vs index)
58
+ unstaged_files = {item.a_path for item in repo.index.diff(None)}
59
+ if rel_path in unstaged_files:
60
+ return "modified"
61
+
62
+ # Check if file is tracked
63
+ try:
64
+ repo.git.ls_files("--error-unmatch", rel_path)
65
+ return "tracked"
66
+ except Exception:
67
+ return "untracked"
68
+ except Exception:
69
+ return "unknown"
70
+
71
+
72
+ def collect_regression_file_info(
73
+ regression_dir: Path,
74
+ repo: Repo | None,
75
+ size_threshold_bytes: int,
76
+ ) -> list[dict[str, Any]]:
77
+ """
78
+ Collect file information from a regression directory.
79
+
80
+ Parameters
81
+ ----------
82
+ regression_dir
83
+ Path to the regression data directory
84
+ repo
85
+ Git repository object, or None if not in a repo
86
+ size_threshold_bytes
87
+ Files larger than this will be flagged as large
88
+
89
+ Returns
90
+ -------
91
+ :
92
+ List of dicts with keys: rel_path, size, is_large, git_status
93
+ """
94
+ files = sorted(regression_dir.rglob("*"))
95
+ files = [f for f in files if f.is_file()]
96
+
97
+ file_info: list[dict[str, Any]] = []
98
+ for file_path in files:
99
+ size = file_path.stat().st_size
100
+ rel_path = str(file_path.relative_to(regression_dir))
101
+ git_status = get_git_status(file_path, repo) if repo else "unknown"
102
+
103
+ file_info.append(
104
+ {
105
+ "rel_path": rel_path,
106
+ "size": size,
107
+ "is_large": size > size_threshold_bytes,
108
+ "git_status": git_status,
109
+ }
110
+ )
111
+
112
+ return file_info
@@ -4,6 +4,30 @@ from rich import box
4
4
  from rich.console import Console
5
5
  from rich.table import Table
6
6
 
7
+ _BYTES_PER_UNIT = 1024
8
+
9
+
10
+ def format_size(size_bytes: int | float) -> str:
11
+ """
12
+ Format file size in human-readable form.
13
+
14
+ Parameters
15
+ ----------
16
+ size_bytes
17
+ Size in bytes
18
+
19
+ Returns
20
+ -------
21
+ :
22
+ Human-readable size string (e.g., "1.5 MB")
23
+ """
24
+ size = float(size_bytes)
25
+ for unit in ("B", "KB", "MB", "GB"):
26
+ if size < _BYTES_PER_UNIT:
27
+ return f"{size:.1f} {unit}"
28
+ size /= _BYTES_PER_UNIT
29
+ return f"{size:.1f} TB"
30
+
7
31
 
8
32
  def parse_facet_filters(filters: list[str] | None) -> dict[str, str]:
9
33
  """
@@ -217,6 +217,7 @@ def _fetch_sample_data(
217
217
  This operation may fail if the test data directory does not exist,
218
218
  as is the case for non-source-based installations.
219
219
  """
220
+ # TODO: Remove
220
221
  fetch_sample_data(force_cleanup=force_cleanup, symlink=symlink)
221
222
 
222
223
 
@@ -0,0 +1,183 @@
1
+ """
2
+ Manage the REF providers.
3
+ """
4
+
5
+ import warnings
6
+ from typing import Annotated
7
+
8
+ import pandas as pd
9
+ import typer
10
+ from loguru import logger
11
+
12
+ from climate_ref.cli._utils import pretty_print_df
13
+ from climate_ref.provider_registry import ProviderRegistry
14
+ from climate_ref_core.providers import CondaDiagnosticProvider, DiagnosticProvider
15
+
16
+ app = typer.Typer(help=__doc__)
17
+
18
+
19
+ @app.command(name="list")
20
+ def list_(ctx: typer.Context) -> None:
21
+ """
22
+ Print the available providers.
23
+ """
24
+ config = ctx.obj.config
25
+ db = ctx.obj.database
26
+ console = ctx.obj.console
27
+ provider_registry = ProviderRegistry.build_from_config(config, db)
28
+
29
+ def get_env(provider: DiagnosticProvider) -> str:
30
+ env = ""
31
+ if isinstance(provider, CondaDiagnosticProvider):
32
+ env = f"{provider.env_path}"
33
+ if not provider.env_path.exists():
34
+ env += " (not installed)"
35
+ return env
36
+
37
+ def get_data_path(provider: DiagnosticProvider) -> str:
38
+ """Get the data cache path for a provider."""
39
+ data_path = provider.get_data_path()
40
+ if data_path is None:
41
+ return ""
42
+ path_str = str(data_path)
43
+ if not data_path.exists():
44
+ path_str += " (not fetched)"
45
+ return path_str
46
+
47
+ results_df = pd.DataFrame(
48
+ [
49
+ {
50
+ "provider": provider.slug,
51
+ "version": provider.version,
52
+ "conda environment": get_env(provider),
53
+ "data path": get_data_path(provider),
54
+ }
55
+ for provider in provider_registry.providers
56
+ ]
57
+ )
58
+ pretty_print_df(results_df, console=console)
59
+
60
+
61
+ @app.command(deprecated=True)
62
+ def create_env(
63
+ ctx: typer.Context,
64
+ provider: Annotated[
65
+ str | None,
66
+ typer.Option(help="Only install the environment for the named provider."),
67
+ ] = None,
68
+ ) -> None:
69
+ """
70
+ Create a conda environment containing the provider software.
71
+
72
+ .. deprecated::
73
+ Use `ref providers setup` instead, which handles both environment creation
74
+ and data fetching in a single command.
75
+
76
+ If no provider is specified, all providers will be installed.
77
+ If the provider is up to date or does not use a conda environment, it will be skipped.
78
+ """
79
+ warnings.warn(
80
+ "create-env is deprecated. Use 'ref providers setup' instead.",
81
+ DeprecationWarning,
82
+ stacklevel=2,
83
+ )
84
+ config = ctx.obj.config
85
+ db = ctx.obj.database
86
+ providers = ProviderRegistry.build_from_config(config, db).providers
87
+
88
+ if provider is not None:
89
+ available = ", ".join([f'"{p.slug}"' for p in providers])
90
+ providers = [p for p in providers if p.slug == provider]
91
+ if not providers:
92
+ msg = f'Provider "{provider}" not available. Choose from: {available}'
93
+ logger.error(msg)
94
+ raise typer.Exit(code=1)
95
+
96
+ for provider_ in providers:
97
+ txt = f"conda environment for provider {provider_.slug}"
98
+ if isinstance(provider_, CondaDiagnosticProvider):
99
+ logger.info(f"Creating {txt} in {provider_.env_path}")
100
+ provider_.create_env()
101
+ logger.info(f"Finished creating {txt}")
102
+ else:
103
+ logger.info(f"Skipping creating {txt} because it does not use conda environments.")
104
+
105
+ list_(ctx)
106
+
107
+
108
+ @app.command()
109
+ def setup(
110
+ ctx: typer.Context,
111
+ provider: Annotated[
112
+ str | None,
113
+ typer.Option(help="Only run setup for the named provider."),
114
+ ] = None,
115
+ skip_env: Annotated[
116
+ bool,
117
+ typer.Option(help="Skip environment setup (e.g., conda)."),
118
+ ] = False,
119
+ skip_data: Annotated[
120
+ bool,
121
+ typer.Option(help="Skip data fetching."),
122
+ ] = False,
123
+ validate_only: Annotated[
124
+ bool,
125
+ typer.Option(help="Only validate setup, don't run it."),
126
+ ] = False,
127
+ ) -> None:
128
+ """
129
+ Run provider setup for offline execution.
130
+
131
+ This command prepares all providers for offline execution by:
132
+
133
+ 1. Creating conda environments (if applicable)
134
+
135
+ 2. Fetching required reference datasets to pooch cache
136
+
137
+ All operations are idempotent and safe to run multiple times.
138
+ Run this on a login node with internet access before solving on compute nodes.
139
+ """
140
+ config = ctx.obj.config
141
+ db = ctx.obj.database
142
+ console = ctx.obj.console
143
+ providers = ProviderRegistry.build_from_config(config, db).providers
144
+
145
+ if provider is not None:
146
+ available = ", ".join([f'"{p.slug}"' for p in providers])
147
+ providers = [p for p in providers if p.slug == provider]
148
+ if not providers:
149
+ msg = f'Provider "{provider}" not available. Choose from: {available}'
150
+ logger.error(msg)
151
+ raise typer.Exit(code=1)
152
+
153
+ failed_providers: list[str] = []
154
+
155
+ for provider_ in providers:
156
+ if validate_only:
157
+ is_valid = provider_.validate_setup(config)
158
+ status = "[green]valid[/green]" if is_valid else "[red]invalid[/red]"
159
+ console.print(f"Provider {provider_.slug}: {status}")
160
+ if not is_valid:
161
+ failed_providers.append(provider_.slug)
162
+ continue
163
+
164
+ logger.info(f"Setting up provider {provider_.slug}")
165
+ try:
166
+ provider_.setup(config, skip_env=skip_env, skip_data=skip_data)
167
+ is_valid = provider_.validate_setup(config)
168
+ if not is_valid:
169
+ logger.error(f"Provider {provider_.slug} setup completed but validation failed")
170
+ failed_providers.append(provider_.slug)
171
+ else:
172
+ logger.info(f"Finished setting up provider {provider_.slug}")
173
+ except Exception as e:
174
+ logger.opt(exception=True).error(f"Failed to setup provider {provider_.slug}: {e}")
175
+ failed_providers.append(provider_.slug)
176
+
177
+ if failed_providers:
178
+ msg = f"Setup failed for providers: {', '.join(failed_providers)}"
179
+ logger.error(msg)
180
+ raise typer.Exit(code=1)
181
+
182
+ if not validate_only:
183
+ list_(ctx)