climate-ref 0.8.1__py3-none-any.whl → 0.9.0__py3-none-any.whl

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.
@@ -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
climate_ref/cli/_utils.py CHANGED
@@ -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
 
@@ -2,6 +2,7 @@
2
2
  Manage the REF providers.
3
3
  """
4
4
 
5
+ import warnings
5
6
  from typing import Annotated
6
7
 
7
8
  import pandas as pd
@@ -33,12 +34,23 @@ def list_(ctx: typer.Context) -> None:
33
34
  env += " (not installed)"
34
35
  return env
35
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
+
36
47
  results_df = pd.DataFrame(
37
48
  [
38
49
  {
39
50
  "provider": provider.slug,
40
51
  "version": provider.version,
41
52
  "conda environment": get_env(provider),
53
+ "data path": get_data_path(provider),
42
54
  }
43
55
  for provider in provider_registry.providers
44
56
  ]
@@ -46,7 +58,7 @@ def list_(ctx: typer.Context) -> None:
46
58
  pretty_print_df(results_df, console=console)
47
59
 
48
60
 
49
- @app.command()
61
+ @app.command(deprecated=True)
50
62
  def create_env(
51
63
  ctx: typer.Context,
52
64
  provider: Annotated[
@@ -57,9 +69,18 @@ def create_env(
57
69
  """
58
70
  Create a conda environment containing the provider software.
59
71
 
72
+ .. deprecated::
73
+ Use `ref providers setup` instead, which handles both environment creation
74
+ and data fetching in a single command.
75
+
60
76
  If no provider is specified, all providers will be installed.
61
- If the provider is up to date or does not use a virtual environment, it will be skipped.
77
+ If the provider is up to date or does not use a conda environment, it will be skipped.
62
78
  """
79
+ warnings.warn(
80
+ "create-env is deprecated. Use 'ref providers setup' instead.",
81
+ DeprecationWarning,
82
+ stacklevel=2,
83
+ )
63
84
  config = ctx.obj.config
64
85
  db = ctx.obj.database
65
86
  providers = ProviderRegistry.build_from_config(config, db).providers
@@ -73,12 +94,90 @@ def create_env(
73
94
  raise typer.Exit(code=1)
74
95
 
75
96
  for provider_ in providers:
76
- txt = f"virtual environment for provider {provider_.slug}"
97
+ txt = f"conda environment for provider {provider_.slug}"
77
98
  if isinstance(provider_, CondaDiagnosticProvider):
78
99
  logger.info(f"Creating {txt} in {provider_.env_path}")
79
100
  provider_.create_env()
80
101
  logger.info(f"Finished creating {txt}")
81
102
  else:
82
- logger.info(f"Skipping creating {txt} because it does use virtual environments.")
103
+ logger.info(f"Skipping creating {txt} because it does not use conda environments.")
83
104
 
84
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)