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.
- climate_ref/cli/__init__.py +5 -2
- climate_ref/cli/_git_utils.py +112 -0
- climate_ref/cli/_utils.py +24 -0
- climate_ref/cli/datasets.py +1 -0
- climate_ref/cli/providers.py +103 -4
- climate_ref/cli/test_cases.py +729 -0
- climate_ref/config.py +1 -1
- climate_ref/database.py +23 -0
- climate_ref/datasets/__init__.py +15 -11
- climate_ref/datasets/base.py +11 -17
- climate_ref/datasets/cmip6.py +1 -1
- climate_ref/solver.py +1 -1
- climate_ref/testing.py +115 -13
- {climate_ref-0.8.1.dist-info → climate_ref-0.9.0.dist-info}/METADATA +2 -1
- {climate_ref-0.8.1.dist-info → climate_ref-0.9.0.dist-info}/RECORD +19 -17
- {climate_ref-0.8.1.dist-info → climate_ref-0.9.0.dist-info}/WHEEL +0 -0
- {climate_ref-0.8.1.dist-info → climate_ref-0.9.0.dist-info}/entry_points.txt +0 -0
- {climate_ref-0.8.1.dist-info → climate_ref-0.9.0.dist-info}/licenses/LICENCE +0 -0
- {climate_ref-0.8.1.dist-info → climate_ref-0.9.0.dist-info}/licenses/NOTICE +0 -0
climate_ref/cli/__init__.py
CHANGED
|
@@ -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.
|
|
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
|
"""
|
climate_ref/cli/datasets.py
CHANGED
climate_ref/cli/providers.py
CHANGED
|
@@ -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
|
|
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"
|
|
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
|
|
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)
|