climate-ref 0.5.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.
Files changed (44) hide show
  1. climate_ref/__init__.py +30 -0
  2. climate_ref/_config_helpers.py +214 -0
  3. climate_ref/alembic.ini +114 -0
  4. climate_ref/cli/__init__.py +138 -0
  5. climate_ref/cli/_utils.py +68 -0
  6. climate_ref/cli/config.py +28 -0
  7. climate_ref/cli/datasets.py +205 -0
  8. climate_ref/cli/executions.py +201 -0
  9. climate_ref/cli/providers.py +84 -0
  10. climate_ref/cli/solve.py +23 -0
  11. climate_ref/config.py +475 -0
  12. climate_ref/constants.py +8 -0
  13. climate_ref/database.py +223 -0
  14. climate_ref/dataset_registry/obs4ref_reference.txt +2 -0
  15. climate_ref/dataset_registry/sample_data.txt +60 -0
  16. climate_ref/datasets/__init__.py +40 -0
  17. climate_ref/datasets/base.py +214 -0
  18. climate_ref/datasets/cmip6.py +202 -0
  19. climate_ref/datasets/obs4mips.py +224 -0
  20. climate_ref/datasets/pmp_climatology.py +15 -0
  21. climate_ref/datasets/utils.py +16 -0
  22. climate_ref/executor/__init__.py +274 -0
  23. climate_ref/executor/local.py +89 -0
  24. climate_ref/migrations/README +22 -0
  25. climate_ref/migrations/env.py +139 -0
  26. climate_ref/migrations/script.py.mako +26 -0
  27. climate_ref/migrations/versions/2025-05-02T1418_341a4aa2551e_regenerate.py +292 -0
  28. climate_ref/models/__init__.py +33 -0
  29. climate_ref/models/base.py +42 -0
  30. climate_ref/models/dataset.py +206 -0
  31. climate_ref/models/diagnostic.py +61 -0
  32. climate_ref/models/execution.py +306 -0
  33. climate_ref/models/metric_value.py +195 -0
  34. climate_ref/models/provider.py +39 -0
  35. climate_ref/provider_registry.py +146 -0
  36. climate_ref/py.typed +0 -0
  37. climate_ref/solver.py +395 -0
  38. climate_ref/testing.py +109 -0
  39. climate_ref-0.5.0.dist-info/METADATA +97 -0
  40. climate_ref-0.5.0.dist-info/RECORD +44 -0
  41. climate_ref-0.5.0.dist-info/WHEEL +4 -0
  42. climate_ref-0.5.0.dist-info/entry_points.txt +2 -0
  43. climate_ref-0.5.0.dist-info/licenses/LICENCE +201 -0
  44. climate_ref-0.5.0.dist-info/licenses/NOTICE +3 -0
@@ -0,0 +1,30 @@
1
+ """
2
+ Rapid evaluating CMIP data
3
+ """
4
+
5
+ import importlib.metadata
6
+
7
+ __version__ = importlib.metadata.version("climate-ref")
8
+
9
+
10
+ from climate_ref.testing import SAMPLE_DATA_VERSION
11
+ from climate_ref_core.dataset_registry import dataset_registry_manager
12
+
13
+ # Register the obs4REF data registry
14
+ dataset_registry_manager.register(
15
+ "obs4ref",
16
+ "https://pub-b093171261094c4ea9adffa01f94ee06.r2.dev/",
17
+ package="climate_ref.dataset_registry",
18
+ resource="obs4ref_reference.txt",
19
+ )
20
+ # Register the sample data registry -- used for testing
21
+ dataset_registry_manager.register(
22
+ "sample-data",
23
+ "https://raw.githubusercontent.com/Climate-REF/ref-sample-data/refs/tags/{version}/data/",
24
+ package="climate_ref.dataset_registry",
25
+ resource="sample_data.txt",
26
+ version=SAMPLE_DATA_VERSION,
27
+ )
28
+
29
+
30
+ __all__ = ["__version__"]
@@ -0,0 +1,214 @@
1
+ import typing
2
+ from collections.abc import Callable
3
+ from typing import Any, TypeVar, overload
4
+
5
+ import attr
6
+ from attrs import fields
7
+ from cattrs import ClassValidationError, ForbiddenExtraKeysError, IterableValidationError
8
+ from cattrs.v import format_exception as default_format_exception
9
+ from environs.exceptions import EnvError
10
+ from loguru import logger
11
+
12
+ from climate_ref_core.env import env
13
+
14
+ T = TypeVar("T")
15
+ C = TypeVar("C", bound=type)
16
+
17
+
18
+ def _pop_empty(d: dict[str, Any]) -> None:
19
+ keys = list(d.keys())
20
+ for key in keys:
21
+ value = d[key]
22
+ if isinstance(value, dict):
23
+ _pop_empty(value)
24
+ if not value:
25
+ d.pop(key)
26
+
27
+
28
+ def _format_key_exception(exc: BaseException, _: type | None) -> str | None:
29
+ """Format a n error exception."""
30
+ if isinstance(exc, ForbiddenExtraKeysError):
31
+ return f"extra fields found ({', '.join(exc.extra_fields)})"
32
+ else:
33
+ return None
34
+
35
+
36
+ def _format_exception(exc: BaseException, type: type | None) -> str: # noqa: A002
37
+ """Format an exception into a string description of the error.
38
+
39
+ Parameters
40
+ ----------
41
+ exc
42
+ The exception to format into an error message.
43
+ type
44
+ The type that the value was expected to be.
45
+ """
46
+ # Any custom handling of error goes here before falling back to the default
47
+ if isinstance(exc, EnvError): # pragma: no cover
48
+ return str(exc)
49
+
50
+ return default_format_exception(exc, type)
51
+
52
+
53
+ def transform_error(
54
+ exc: ClassValidationError | IterableValidationError | BaseException,
55
+ path: str = "$",
56
+ format_exception: Callable[[BaseException, type | None], str | None] = _format_exception,
57
+ ) -> list[str]:
58
+ """Transform an exception into a list of error messages.
59
+
60
+ This is based on [cattrs.transform_error][cattrs.transform_error],
61
+ but modified to be able to ignore errors
62
+
63
+ To get detailed error messages, the exception should be produced by a converter
64
+ with `detailed_validation` set.
65
+
66
+ By default, the error messages are in the form of `{description} @ {path}`.
67
+
68
+ While traversing the exception and subexceptions, the path is formed:
69
+
70
+ * by appending `.{field_name}` for fields in classes
71
+ * by appending `[{int}]` for indices in iterables, like lists
72
+ * by appending `[{str}]` for keys in mappings, like dictionaries
73
+
74
+ Parameters
75
+ ----------
76
+ exc
77
+ The exception to transform into error messages.
78
+ path
79
+ The root path to use.
80
+ format_exception
81
+ A callable to use to transform `Exceptions` into string descriptions of errors.
82
+ """
83
+ errors = []
84
+
85
+ def _maybe_append_error(exc: BaseException, _: type | None, path: str) -> str | None:
86
+ error_message = format_exception(exc, None)
87
+ if error_message:
88
+ errors.append(f"{error_message} @ {path}")
89
+ return None
90
+
91
+ if isinstance(exc, IterableValidationError):
92
+ iterable_validation_notes, without = exc.group_exceptions()
93
+ for inner_exc, iterable_note in iterable_validation_notes:
94
+ p = f"{path}[{iterable_note.index!r}]"
95
+ if isinstance(inner_exc, ClassValidationError | IterableValidationError):
96
+ errors.extend(transform_error(inner_exc, p, format_exception))
97
+ else:
98
+ _maybe_append_error(inner_exc, iterable_note.type, p)
99
+ for inner_exc in without:
100
+ _maybe_append_error(inner_exc, None, path)
101
+ elif isinstance(exc, ClassValidationError):
102
+ class_validation_notes, without = exc.group_exceptions()
103
+ for inner_exc, class_note in class_validation_notes:
104
+ p = f"{path}.{class_note.name}"
105
+ if isinstance(inner_exc, ClassValidationError | IterableValidationError):
106
+ errors.extend(transform_error(inner_exc, p, format_exception))
107
+ else:
108
+ _maybe_append_error(inner_exc, class_note.type, p)
109
+ for inner_exc in without:
110
+ _maybe_append_error(inner_exc, None, path)
111
+ else:
112
+ _maybe_append_error(exc, None, path)
113
+
114
+ return errors
115
+
116
+
117
+ def _environment_override(value: T, env_name: str, convertor: Callable[[Any], T] | None = None) -> T:
118
+ try:
119
+ env_value = env.str(env_name)
120
+ except EnvError:
121
+ return value
122
+
123
+ logger.debug(f"Overriding {env_name} with {env_value}")
124
+ if convertor:
125
+ return convertor(env_value)
126
+
127
+ return typing.cast(T, env_value)
128
+
129
+
130
+ def _environ_post_init(cls: Any) -> None:
131
+ for f in fields(cls.__class__):
132
+ if f.metadata.get("env"):
133
+ env_name = f"{cls._prefix}_{f.metadata['env']}"
134
+
135
+ # This is a bit of a hack to get around the fact that we can't update values on frozen classes
136
+ # https://www.attrs.org/en/stable/how-does-it-work.html#how-frozen
137
+ object.__setattr__(
138
+ cls, f.name, _environment_override(getattr(cls, f.name), env_name, f.converter)
139
+ )
140
+
141
+
142
+ @overload
143
+ def config(
144
+ *,
145
+ prefix: str,
146
+ frozen: bool = False,
147
+ ) -> Callable[[C], C]: ...
148
+
149
+
150
+ @overload
151
+ def config(
152
+ maybe_cls: C,
153
+ *,
154
+ prefix: str,
155
+ frozen: bool = False,
156
+ ) -> C: ...
157
+
158
+
159
+ def config(
160
+ maybe_cls: C | None = None,
161
+ *,
162
+ prefix: str,
163
+ frozen: bool = False,
164
+ ) -> C | Callable[[C], C]:
165
+ """
166
+ Make a class a configuration class.
167
+
168
+ Parameters
169
+ ----------
170
+ prefix:
171
+ The prefix that is used for the env variables.
172
+
173
+ This is be prepended to all the fields that use an `env_field`.
174
+ frozen:
175
+ The configuration will be immutable after instantiation, if `True`.
176
+ """
177
+
178
+ def wrap(cls: C) -> C:
179
+ setattr(cls, "_prefix", prefix)
180
+ setattr(cls, "__attrs_post_init__", _environ_post_init)
181
+
182
+ return attr.s(cls, frozen=frozen, slots=True)
183
+
184
+ # maybe_cls's type depends on the usage of the decorator. It's a class
185
+ # if it's used as `@attrs` but `None` if used as `@attrs()`.
186
+ if maybe_cls is None:
187
+ return wrap
188
+
189
+ return wrap(maybe_cls)
190
+
191
+
192
+ def env_field(name: str, **kwargs: Any) -> Any:
193
+ """
194
+ Create a field with an environment variable override.
195
+
196
+ This field will use a value from the environment if it is set.
197
+
198
+ This field requires the class to be decorated with `config` to work.
199
+ The environment variable name is constructed by prefixing the field name with the class prefix.
200
+
201
+ Parameters
202
+ ----------
203
+ name
204
+ Name of the environment variable to use without a prefix
205
+ kwargs
206
+ Additional arguments to pass to the field
207
+
208
+ Returns
209
+ -------
210
+ field
211
+ The field with the environment variable override
212
+
213
+ """
214
+ return attr.attrib(**kwargs, metadata={"env": name})
@@ -0,0 +1,114 @@
1
+ # A generic, single database configuration.
2
+
3
+ [alembic]
4
+ # path to migration scripts
5
+ # Use forward slashes (/) also on windows to provide an os agnostic path
6
+ script_location = %(here)s/migrations
7
+
8
+ # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
9
+ # Uncomment the line below if you want the files to be prepended with date and time
10
+ # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
11
+ # for all available tokens
12
+ file_template = %%(year)d-%%(month).2d-%%(day).2dT%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
13
+
14
+ # sys.path path, will be prepended to sys.path if present.
15
+ # defaults to the current working directory.
16
+ prepend_sys_path = .
17
+
18
+ # timezone to use when rendering the date within the migration file
19
+ # as well as the filename.
20
+ # If specified, requires the python>=3.9 or backports.zoneinfo library.
21
+ # Any required deps can installed by adding `alembic[tz]` to the pip requirements
22
+ # string value is passed to ZoneInfo()
23
+ # leave blank for localtime
24
+ # timezone =
25
+
26
+ # max length of characters to apply to the "slug" field
27
+ # truncate_slug_length = 40
28
+
29
+ # set to 'true' to run the environment during
30
+ # the 'revision' command, regardless of autogenerate
31
+ # revision_environment = false
32
+
33
+ # set to 'true' to allow .pyc and .pyo files without
34
+ # a source .py file to be detected as revisions in the
35
+ # versions/ directory
36
+ # sourceless = false
37
+
38
+ # version location specification; This defaults
39
+ # to alembic/versions. When using multiple version
40
+ # directories, initial revisions must be specified with --version-path.
41
+ # The path separator used here should be the separator specified by "version_path_separator" below.
42
+ # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
43
+
44
+ # version path separator; As mentioned above, this is the character used to split
45
+ # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
46
+ # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
47
+ # Valid values for version_path_separator are:
48
+ #
49
+ # version_path_separator = :
50
+ # version_path_separator = ;
51
+ # version_path_separator = space
52
+ # version_path_separator = newline
53
+ version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
54
+
55
+ # set to 'true' to search source files recursively
56
+ # in each "version_locations" directory
57
+ # new in Alembic version 1.10
58
+ # recursive_version_locations = false
59
+
60
+ # the output encoding used when revision files
61
+ # are written from script.py.mako
62
+ # output_encoding = utf-8
63
+
64
+
65
+ [post_write_hooks]
66
+ # post_write_hooks defines scripts or Python functions that are run
67
+ # on newly generated revision scripts. See the documentation for further
68
+ # detail and examples
69
+
70
+ # lint with attempts to fix using "ruff"
71
+ hooks = ruff-fix, ruff-format
72
+
73
+ ruff-fix.type = exec
74
+ ruff-fix.executable = ruff
75
+ ruff-fix.options = check -q --fix REVISION_SCRIPT_FILENAME
76
+
77
+ ruff-format.type = exec
78
+ ruff-format.executable = ruff
79
+ ruff-format.options = format -q REVISION_SCRIPT_FILENAME
80
+
81
+ # Logging configuration
82
+ [loggers]
83
+ keys = root,sqlalchemy,alembic
84
+
85
+ [handlers]
86
+ keys = console
87
+
88
+ [formatters]
89
+ keys = generic
90
+
91
+ [logger_root]
92
+ level = WARN
93
+ handlers = console
94
+ qualname =
95
+
96
+ [logger_sqlalchemy]
97
+ level = WARN
98
+ handlers =
99
+ qualname = sqlalchemy.engine
100
+
101
+ [logger_alembic]
102
+ level = INFO
103
+ handlers =
104
+ qualname = alembic
105
+
106
+ [handler_console]
107
+ class = StreamHandler
108
+ args = (sys.stderr,)
109
+ level = NOTSET
110
+ formatter = generic
111
+
112
+ [formatter_generic]
113
+ format = %(levelname)-5.5s [%(name)s] %(message)s
114
+ datefmt = %H:%M:%S
@@ -0,0 +1,138 @@
1
+ """Entrypoint for the CLI"""
2
+
3
+ import importlib
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import Annotated, Optional
7
+
8
+ import typer
9
+ from attrs import define
10
+ from loguru import logger
11
+
12
+ from climate_ref import __version__
13
+ from climate_ref.cli import config, datasets, executions, providers, solve
14
+ from climate_ref.config import Config
15
+ from climate_ref.constants import config_filename
16
+ from climate_ref.database import Database
17
+ from climate_ref_core import __version__ as __core_version__
18
+ from climate_ref_core.logging import add_log_handler
19
+
20
+
21
+ class LogLevel(str, Enum):
22
+ """
23
+ Log levels for the CLI
24
+ """
25
+
26
+ Normal = "WARNING"
27
+ Debug = "DEBUG"
28
+ Info = "INFO"
29
+
30
+
31
+ @define
32
+ class CLIContext:
33
+ """
34
+ Context object that can be passed to commands
35
+ """
36
+
37
+ config: Config
38
+ database: Database
39
+
40
+
41
+ def _version_callback(value: bool) -> None:
42
+ if value:
43
+ print(f"climate_ref: {__version__}")
44
+ print(f"climate_ref-core: {__core_version__}")
45
+ raise typer.Exit()
46
+
47
+
48
+ def _load_config(configuration_directory: Path | None = None) -> Config:
49
+ """
50
+ Load the configuration from the specified directory
51
+
52
+ Parameters
53
+ ----------
54
+ configuration_directory
55
+ The directory to load the configuration from
56
+
57
+ If the specified directory is not found, the process will exit with an exit code of 1
58
+
59
+ If None, the default configuration will be loaded
60
+
61
+ Returns
62
+ -------
63
+ :
64
+ The configuration loaded from the specified directory
65
+ """
66
+ try:
67
+ if configuration_directory:
68
+ config = Config.load(configuration_directory / config_filename, allow_missing=False)
69
+ else:
70
+ config = Config.default()
71
+ except FileNotFoundError:
72
+ typer.secho("Configuration file not found", fg=typer.colors.RED)
73
+ raise typer.Exit(1)
74
+ return config
75
+
76
+
77
+ def build_app() -> typer.Typer:
78
+ """
79
+ Build the CLI app
80
+
81
+ This registers all the commands and subcommands of the CLI app.
82
+ Some commands may not be available if certain dependencies are not installed,
83
+ for example the Celery CLI is only available if the `climate-ref-celery` package is installed.
84
+
85
+ Returns
86
+ -------
87
+ :
88
+ The CLI app
89
+ """
90
+ app = typer.Typer(name="climate_ref", no_args_is_help=True)
91
+
92
+ app.command(name="solve")(solve.solve)
93
+ app.add_typer(config.app, name="config")
94
+ app.add_typer(datasets.app, name="datasets")
95
+ app.add_typer(executions.app, name="executions")
96
+ app.add_typer(providers.app, name="providers")
97
+
98
+ try:
99
+ celery_app = importlib.import_module("climate_ref_celery.cli").app
100
+
101
+ app.add_typer(celery_app, name="celery")
102
+ except ImportError:
103
+ logger.debug("Celery CLI not available")
104
+
105
+ return app
106
+
107
+
108
+ app = build_app()
109
+
110
+
111
+ @app.callback()
112
+ def main(
113
+ ctx: typer.Context,
114
+ configuration_directory: Annotated[Path | None, typer.Option(help="Configuration directory")] = None,
115
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
116
+ log_level: Annotated[LogLevel, typer.Option(case_sensitive=False)] = LogLevel.Normal,
117
+ version: Annotated[
118
+ Optional[bool],
119
+ typer.Option("--version", callback=_version_callback, is_eager=True),
120
+ ] = None,
121
+ ) -> None:
122
+ """
123
+ climate_ref: A CLI for the CMIP Rapid Evaluation Framework
124
+ """
125
+ if verbose:
126
+ log_level = LogLevel.Debug
127
+
128
+ logger.remove()
129
+ add_log_handler(level=log_level.value)
130
+
131
+ config = _load_config(configuration_directory)
132
+ config.log_level = log_level.value
133
+
134
+ ctx.obj = CLIContext(config=config, database=Database.from_config(config))
135
+
136
+
137
+ if __name__ == "__main__":
138
+ app()
@@ -0,0 +1,68 @@
1
+ import pandas as pd
2
+ from loguru import logger
3
+ from rich import box
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+
8
+ def df_to_table(df: pd.DataFrame, max_col_count: int = -1) -> Table:
9
+ """
10
+ Convert a DataFrame to a rich Table instance
11
+
12
+ Parameters
13
+ ----------
14
+ df
15
+ DataFrame to convert
16
+ max_col_count
17
+ Maximum number of columns to display
18
+
19
+ If the DataFrame has more columns than this, the excess columns will be truncated
20
+ If set to -1, all columns will be displayed.
21
+ For very wide DataFrames, then this may result in no values at all being displayed
22
+ if the column-width ends up being less than 1 char.
23
+
24
+ Returns
25
+ -------
26
+ Rich Table instance representing the DataFrame
27
+ """
28
+ # Initiate a Table instance to be modified
29
+ if max_col_count > 0 and len(df.columns) > max_col_count:
30
+ logger.warning(f"Too many columns to display ({len(df.columns)}), truncating to {max_col_count}")
31
+ df = df.iloc[:, :max_col_count]
32
+
33
+ table = Table(*[str(column) for column in df.columns])
34
+
35
+ for index, value_list in enumerate(df.values.tolist()):
36
+ row = [str(x) for x in value_list]
37
+ table.add_row(*row)
38
+
39
+ # Update the style of the table
40
+ table.row_styles = ["none", "dim"]
41
+ table.box = box.SIMPLE_HEAD
42
+
43
+ return table
44
+
45
+
46
+ def pretty_print_df(df: pd.DataFrame, console: Console | None = None) -> None:
47
+ """
48
+ Pretty print a DataFrame
49
+
50
+ Parameters
51
+ ----------
52
+ df
53
+ DataFrame to print
54
+ console
55
+ Console to use for printing
56
+
57
+ If not provided, a new Console instance will be created
58
+ """
59
+ # Drop duplicates as they are not informative to CLI users.
60
+ df = df.drop_duplicates()
61
+
62
+ if console is None:
63
+ console = Console()
64
+
65
+ max_col_count = console.width // 10
66
+ table = df_to_table(df, max_col_count=max_col_count)
67
+
68
+ console.print(table)
@@ -0,0 +1,28 @@
1
+ """
2
+ View and update the REF configuration
3
+ """
4
+
5
+ import typer
6
+
7
+ app = typer.Typer(help=__doc__)
8
+
9
+
10
+ @app.command(name="list")
11
+ def list_(ctx: typer.Context) -> None:
12
+ """
13
+ Print the current climate_ref configuration
14
+
15
+ If a configuration directory is provided,
16
+ the configuration will attempt to load from the specified directory.
17
+ """
18
+ config = ctx.obj.config
19
+
20
+ print(config.dumps(defaults=True))
21
+
22
+
23
+ @app.command()
24
+ def update() -> None:
25
+ """
26
+ Update a configuration value
27
+ """
28
+ print("config")