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.
- climate_ref/__init__.py +30 -0
- climate_ref/_config_helpers.py +214 -0
- climate_ref/alembic.ini +114 -0
- climate_ref/cli/__init__.py +138 -0
- climate_ref/cli/_utils.py +68 -0
- climate_ref/cli/config.py +28 -0
- climate_ref/cli/datasets.py +205 -0
- climate_ref/cli/executions.py +201 -0
- climate_ref/cli/providers.py +84 -0
- climate_ref/cli/solve.py +23 -0
- climate_ref/config.py +475 -0
- climate_ref/constants.py +8 -0
- climate_ref/database.py +223 -0
- climate_ref/dataset_registry/obs4ref_reference.txt +2 -0
- climate_ref/dataset_registry/sample_data.txt +60 -0
- climate_ref/datasets/__init__.py +40 -0
- climate_ref/datasets/base.py +214 -0
- climate_ref/datasets/cmip6.py +202 -0
- climate_ref/datasets/obs4mips.py +224 -0
- climate_ref/datasets/pmp_climatology.py +15 -0
- climate_ref/datasets/utils.py +16 -0
- climate_ref/executor/__init__.py +274 -0
- climate_ref/executor/local.py +89 -0
- climate_ref/migrations/README +22 -0
- climate_ref/migrations/env.py +139 -0
- climate_ref/migrations/script.py.mako +26 -0
- climate_ref/migrations/versions/2025-05-02T1418_341a4aa2551e_regenerate.py +292 -0
- climate_ref/models/__init__.py +33 -0
- climate_ref/models/base.py +42 -0
- climate_ref/models/dataset.py +206 -0
- climate_ref/models/diagnostic.py +61 -0
- climate_ref/models/execution.py +306 -0
- climate_ref/models/metric_value.py +195 -0
- climate_ref/models/provider.py +39 -0
- climate_ref/provider_registry.py +146 -0
- climate_ref/py.typed +0 -0
- climate_ref/solver.py +395 -0
- climate_ref/testing.py +109 -0
- climate_ref-0.5.0.dist-info/METADATA +97 -0
- climate_ref-0.5.0.dist-info/RECORD +44 -0
- climate_ref-0.5.0.dist-info/WHEEL +4 -0
- climate_ref-0.5.0.dist-info/entry_points.txt +2 -0
- climate_ref-0.5.0.dist-info/licenses/LICENCE +201 -0
- climate_ref-0.5.0.dist-info/licenses/NOTICE +3 -0
climate_ref/__init__.py
ADDED
|
@@ -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})
|
climate_ref/alembic.ini
ADDED
|
@@ -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")
|