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/config.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management
|
|
3
|
+
|
|
4
|
+
The REF uses a tiered configuration model,
|
|
5
|
+
where configuration is sourced from a hierarchy of different places.
|
|
6
|
+
|
|
7
|
+
Each configuration value has a default which is used if not other configuration is available.
|
|
8
|
+
Then configuration is loaded from a `.toml` file which overrides any default values.
|
|
9
|
+
Finally, some configuration can be overridden at runtime using environment variables,
|
|
10
|
+
which always take precedence over any other configuration values.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# The basics of the configuration management takes a lot of inspiration from the
|
|
14
|
+
# `esgpull` configuration management system with some of the extra complexity removed.
|
|
15
|
+
# https://github.com/ESGF/esgf-download/blob/main/esgpull/config.py
|
|
16
|
+
|
|
17
|
+
import importlib.resources
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
import tomlkit
|
|
22
|
+
from attr import Factory
|
|
23
|
+
from attrs import define, field
|
|
24
|
+
from cattrs import Converter
|
|
25
|
+
from cattrs.gen import make_dict_unstructure_fn, override
|
|
26
|
+
from loguru import logger
|
|
27
|
+
from tomlkit import TOMLDocument
|
|
28
|
+
|
|
29
|
+
from climate_ref._config_helpers import (
|
|
30
|
+
_format_exception,
|
|
31
|
+
_format_key_exception,
|
|
32
|
+
_pop_empty,
|
|
33
|
+
config,
|
|
34
|
+
env_field,
|
|
35
|
+
transform_error,
|
|
36
|
+
)
|
|
37
|
+
from climate_ref.constants import config_filename
|
|
38
|
+
from climate_ref.executor import import_executor_cls
|
|
39
|
+
from climate_ref_core.env import env
|
|
40
|
+
from climate_ref_core.exceptions import InvalidExecutorException
|
|
41
|
+
from climate_ref_core.executor import Executor
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from climate_ref.database import Database
|
|
45
|
+
|
|
46
|
+
env_prefix = "REF"
|
|
47
|
+
"""
|
|
48
|
+
Prefix for the environment variables used by the REF
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def ensure_absolute_path(path: str | Path) -> Path:
|
|
53
|
+
"""
|
|
54
|
+
Ensure that the path is absolute
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
path
|
|
59
|
+
Path to check
|
|
60
|
+
|
|
61
|
+
Returns
|
|
62
|
+
-------
|
|
63
|
+
Absolute path
|
|
64
|
+
"""
|
|
65
|
+
if isinstance(path, str):
|
|
66
|
+
path = Path(path)
|
|
67
|
+
return path.resolve()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@config(prefix=env_prefix)
|
|
71
|
+
class PathConfig:
|
|
72
|
+
"""
|
|
73
|
+
Common paths used by the REF application
|
|
74
|
+
|
|
75
|
+
/// admonition | Warning
|
|
76
|
+
type: warning
|
|
77
|
+
|
|
78
|
+
These paths must be common across all systems that the REF is being run
|
|
79
|
+
///
|
|
80
|
+
|
|
81
|
+
If any of these paths are specified as relative paths,
|
|
82
|
+
they will be resolved to absolute paths.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
log: Path = env_field(name="LOG_ROOT", converter=ensure_absolute_path)
|
|
86
|
+
"""
|
|
87
|
+
Directory to store log files from the compute engine
|
|
88
|
+
|
|
89
|
+
This is not currently used by the REF, but is included for future use.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
scratch: Path = env_field(name="SCRATCH_ROOT", converter=ensure_absolute_path)
|
|
93
|
+
"""
|
|
94
|
+
Shared scratch space for the REF.
|
|
95
|
+
|
|
96
|
+
This directory is used to write the intermediate executions of a diagnostic execution.
|
|
97
|
+
After the diagnostic has been run, the executions will be copied to the executions directory.
|
|
98
|
+
|
|
99
|
+
This directory must be accessible by all the diagnostic services that are used to run the diagnostics,
|
|
100
|
+
but does not need to be mounted in the same location on all the diagnostic services.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
software: Path = env_field(name="SOFTWARE_ROOT", converter=ensure_absolute_path)
|
|
104
|
+
"""
|
|
105
|
+
Shared software space for the REF.
|
|
106
|
+
|
|
107
|
+
This directory is used to store software environments.
|
|
108
|
+
|
|
109
|
+
This directory must be accessible by all the diagnostic services that are used to run the diagnostics,
|
|
110
|
+
and should be mounted in the same location on all the diagnostic services.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
# TODO: This could be another data source option
|
|
114
|
+
results: Path = env_field(name="RESULTS_ROOT", converter=ensure_absolute_path)
|
|
115
|
+
"""
|
|
116
|
+
Path to store the executions
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
dimensions_cv: Path = env_field(name="DIMENSIONS_CV_PATH", converter=Path)
|
|
120
|
+
"""
|
|
121
|
+
Path to a file containing the controlled vocabulary for the dimensions in a CMEC diagnostics bundle
|
|
122
|
+
|
|
123
|
+
This defaults to the controlled vocabulary for the CMIP7 Assessment Fast Track diagnostics,
|
|
124
|
+
which is included in the `climate_ref_core` package.
|
|
125
|
+
|
|
126
|
+
This controlled vocabulary is used to validate the dimensions in the diagnostics bundle.
|
|
127
|
+
If custom diagnostics are implemented,
|
|
128
|
+
this file may need to be extended to include any new dimensions.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
@log.default
|
|
132
|
+
def _log_factory(self) -> Path:
|
|
133
|
+
return env.path("REF_CONFIGURATION").resolve() / "log"
|
|
134
|
+
|
|
135
|
+
@scratch.default
|
|
136
|
+
def _scratch_factory(self) -> Path:
|
|
137
|
+
return env.path("REF_CONFIGURATION").resolve() / "scratch"
|
|
138
|
+
|
|
139
|
+
@software.default
|
|
140
|
+
def _software_factory(self) -> Path:
|
|
141
|
+
return env.path("REF_CONFIGURATION").resolve() / "software"
|
|
142
|
+
|
|
143
|
+
@results.default
|
|
144
|
+
def _results_factory(self) -> Path:
|
|
145
|
+
return env.path("REF_CONFIGURATION").resolve() / "results"
|
|
146
|
+
|
|
147
|
+
@dimensions_cv.default
|
|
148
|
+
def _dimensions_cv_factory(self) -> Path:
|
|
149
|
+
filename = "cv_cmip7_aft.yaml"
|
|
150
|
+
return Path(str(importlib.resources.files("climate_ref_core.pycmec") / filename))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@config(prefix=env_prefix)
|
|
154
|
+
class ExecutorConfig:
|
|
155
|
+
"""
|
|
156
|
+
Configuration to define the executor to use for running diagnostics
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
executor: str = env_field(name="EXECUTOR", default="climate_ref.executor.local.LocalExecutor")
|
|
160
|
+
"""
|
|
161
|
+
Executor to use for running diagnostics
|
|
162
|
+
|
|
163
|
+
This should be the fully qualified name of the executor class
|
|
164
|
+
(e.g. `climate_ref.executor.local.LocalExecutor`).
|
|
165
|
+
The default is to use the local executor.
|
|
166
|
+
The environment variable `REF_EXECUTOR` takes precedence over this configuration value.
|
|
167
|
+
|
|
168
|
+
This class will be used for all executions of diagnostics.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
config: dict[str, Any] = field(factory=dict)
|
|
172
|
+
"""
|
|
173
|
+
Additional configuration for the executor.
|
|
174
|
+
|
|
175
|
+
See the documentation for the executor for the available configuration options.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
def build(self, config: "Config", database: "Database") -> Executor:
|
|
179
|
+
"""
|
|
180
|
+
Create an instance of the executor
|
|
181
|
+
|
|
182
|
+
Returns
|
|
183
|
+
-------
|
|
184
|
+
:
|
|
185
|
+
An executor that can be used to run diagnostics
|
|
186
|
+
"""
|
|
187
|
+
ExecutorCls = import_executor_cls(self.executor)
|
|
188
|
+
kwargs = {
|
|
189
|
+
"config": config,
|
|
190
|
+
"database": database,
|
|
191
|
+
**self.config,
|
|
192
|
+
}
|
|
193
|
+
executor = ExecutorCls(**kwargs)
|
|
194
|
+
|
|
195
|
+
if not isinstance(executor, Executor):
|
|
196
|
+
raise InvalidExecutorException(executor, f"Expected an Executor, got {type(executor)}")
|
|
197
|
+
return executor
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@define
|
|
201
|
+
class DiagnosticProviderConfig:
|
|
202
|
+
"""
|
|
203
|
+
Configuration for the diagnostic providers
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
provider: str
|
|
207
|
+
"""
|
|
208
|
+
Package that contains the diagnostic provider
|
|
209
|
+
|
|
210
|
+
This should be the fully qualified name of the diagnostic provider.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
config: dict[str, Any] = field(factory=dict)
|
|
214
|
+
"""
|
|
215
|
+
Additional configuration for the diagnostic provider.
|
|
216
|
+
|
|
217
|
+
See the documentation for the diagnostic package for the available configuration options.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
# TODO: Additional configuration for narrowing down the diagnostics to run
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@config(prefix=env_prefix)
|
|
224
|
+
class DbConfig:
|
|
225
|
+
"""
|
|
226
|
+
Database configuration
|
|
227
|
+
|
|
228
|
+
We currently only plan to support SQLite and PostgreSQL databases,
|
|
229
|
+
although only SQLite is currently implemented and tested.
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
database_url: str = env_field(name="DATABASE_URL")
|
|
233
|
+
"""
|
|
234
|
+
Database URL that describes the connection to the database.
|
|
235
|
+
|
|
236
|
+
Defaults to sqlite:///{config.paths.db}/climate_ref.db".
|
|
237
|
+
This configuration value will be overridden by the `REF_DATABASE_URL` environment variable.
|
|
238
|
+
|
|
239
|
+
## Schemas
|
|
240
|
+
|
|
241
|
+
postgresql://USER:PASSWORD@HOST:PORT/NAME
|
|
242
|
+
sqlite:///RELATIVE_PATH or sqlite:////ABS_PATH or sqlite:///:memory:
|
|
243
|
+
"""
|
|
244
|
+
run_migrations: bool = field(default=True)
|
|
245
|
+
|
|
246
|
+
@database_url.default
|
|
247
|
+
def _connection_url_factory(self) -> str:
|
|
248
|
+
filename = env.path("REF_CONFIGURATION") / "db" / "climate_ref.db"
|
|
249
|
+
sqlite_url = f"sqlite:///{filename}"
|
|
250
|
+
return sqlite_url
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def default_providers() -> list[DiagnosticProviderConfig]:
|
|
254
|
+
"""
|
|
255
|
+
Default diagnostic provider
|
|
256
|
+
|
|
257
|
+
Used if no diagnostic providers are specified in the configuration
|
|
258
|
+
|
|
259
|
+
Returns
|
|
260
|
+
-------
|
|
261
|
+
:
|
|
262
|
+
List of default diagnostic providers
|
|
263
|
+
""" # noqa: D401
|
|
264
|
+
env_providers = env.list("REF_DIAGNOSTIC_PROVIDERS", default=None)
|
|
265
|
+
if env_providers:
|
|
266
|
+
return [DiagnosticProviderConfig(provider=provider) for provider in env_providers]
|
|
267
|
+
|
|
268
|
+
return [
|
|
269
|
+
DiagnosticProviderConfig(provider="climate_ref_esmvaltool.provider", config={}),
|
|
270
|
+
DiagnosticProviderConfig(provider="climate_ref_ilamb.provider", config={}),
|
|
271
|
+
DiagnosticProviderConfig(provider="climate_ref_pmp.provider", config={}),
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _load_config(config_file: str | Path, doc: dict[str, Any]) -> "Config":
|
|
276
|
+
# Try loading the configuration with strict validation
|
|
277
|
+
try:
|
|
278
|
+
return _converter_defaults.structure(doc, Config)
|
|
279
|
+
except Exception as exc:
|
|
280
|
+
# Find the extra key errors which are displayed as warnings
|
|
281
|
+
key_validation_errors = transform_error(exc, format_exception=_format_key_exception)
|
|
282
|
+
for key_error in key_validation_errors:
|
|
283
|
+
logger.warning(f"Error loading configuration from {config_file}: {key_error}")
|
|
284
|
+
|
|
285
|
+
# Try again with relaxed validation
|
|
286
|
+
return _converter_defaults_relaxed.structure(doc, Config)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@define
|
|
290
|
+
class Config:
|
|
291
|
+
"""
|
|
292
|
+
REF configuration
|
|
293
|
+
|
|
294
|
+
This class is used to store the configuration of the REF application.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
log_level: str = field(default="INFO")
|
|
298
|
+
"""
|
|
299
|
+
Log level of messages that are displayed by the REF
|
|
300
|
+
|
|
301
|
+
This value is overridden if a value is specified via the CLI.
|
|
302
|
+
"""
|
|
303
|
+
paths: PathConfig = Factory(PathConfig) # noqa
|
|
304
|
+
db: DbConfig = Factory(DbConfig) # noqa
|
|
305
|
+
executor: ExecutorConfig = Factory(ExecutorConfig) # noqa
|
|
306
|
+
diagnostic_providers: list[DiagnosticProviderConfig] = Factory(default_providers) # noqa
|
|
307
|
+
_raw: TOMLDocument | None = field(init=False, default=None, repr=False)
|
|
308
|
+
_config_file: Path | None = field(init=False, default=None, repr=False)
|
|
309
|
+
|
|
310
|
+
@classmethod
|
|
311
|
+
def load(cls, config_file: Path, allow_missing: bool = True) -> "Config":
|
|
312
|
+
"""
|
|
313
|
+
Load the configuration from a file
|
|
314
|
+
|
|
315
|
+
Parameters
|
|
316
|
+
----------
|
|
317
|
+
config_file
|
|
318
|
+
Path to the configuration file.
|
|
319
|
+
This should be a TOML file.
|
|
320
|
+
|
|
321
|
+
Returns
|
|
322
|
+
-------
|
|
323
|
+
:
|
|
324
|
+
The configuration loaded from the file
|
|
325
|
+
"""
|
|
326
|
+
if config_file.is_file():
|
|
327
|
+
with config_file.open() as fh:
|
|
328
|
+
doc = tomlkit.load(fh)
|
|
329
|
+
raw = doc
|
|
330
|
+
else:
|
|
331
|
+
if not allow_missing:
|
|
332
|
+
raise FileNotFoundError(f"Configuration file not found: {config_file}")
|
|
333
|
+
|
|
334
|
+
doc = TOMLDocument()
|
|
335
|
+
raw = None
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
config = _load_config(config_file, doc)
|
|
339
|
+
except Exception as exc:
|
|
340
|
+
# If that still fails, error out
|
|
341
|
+
key_validation_errors = transform_error(exc, format_exception=_format_exception)
|
|
342
|
+
for key_error in key_validation_errors:
|
|
343
|
+
logger.error(f"Error loading configuration from {config_file}: {key_error}")
|
|
344
|
+
|
|
345
|
+
# Deliberately not raising "from exc" to avoid long tracebacks from cattrs
|
|
346
|
+
# The transformed error messages are sufficient for debugging
|
|
347
|
+
raise ValueError(f"Error loading configuration from {config_file}") from None
|
|
348
|
+
|
|
349
|
+
config._raw = raw
|
|
350
|
+
config._config_file = config_file
|
|
351
|
+
return config
|
|
352
|
+
|
|
353
|
+
def refresh(self) -> "Config":
|
|
354
|
+
"""
|
|
355
|
+
Refresh the configuration values
|
|
356
|
+
|
|
357
|
+
This returns a new instance of the configuration based on the same configuration file and
|
|
358
|
+
any current environment variables.
|
|
359
|
+
"""
|
|
360
|
+
if self._config_file is None:
|
|
361
|
+
raise ValueError("No configuration file specified")
|
|
362
|
+
return self.load(self._config_file)
|
|
363
|
+
|
|
364
|
+
def save(self, config_file: Path | None = None) -> None:
|
|
365
|
+
"""
|
|
366
|
+
Save the configuration as a TOML file
|
|
367
|
+
|
|
368
|
+
The configuration will be saved to the specified file.
|
|
369
|
+
If no file is specified, the configuration will be saved to the file
|
|
370
|
+
that was used to load the configuration.
|
|
371
|
+
|
|
372
|
+
Parameters
|
|
373
|
+
----------
|
|
374
|
+
config_file
|
|
375
|
+
The file to save the configuration to
|
|
376
|
+
|
|
377
|
+
Raises
|
|
378
|
+
------
|
|
379
|
+
ValueError
|
|
380
|
+
If no configuration file is specified and the configuration was not loaded from a file
|
|
381
|
+
"""
|
|
382
|
+
if config_file is None:
|
|
383
|
+
if self._config_file is None: # pragma: no cover
|
|
384
|
+
# I'm not sure if this is possible
|
|
385
|
+
raise ValueError("No configuration file specified")
|
|
386
|
+
config_file = self._config_file
|
|
387
|
+
|
|
388
|
+
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
389
|
+
|
|
390
|
+
with open(config_file, "w") as fh:
|
|
391
|
+
fh.write(self.dumps())
|
|
392
|
+
|
|
393
|
+
@classmethod
|
|
394
|
+
def default(cls) -> "Config":
|
|
395
|
+
"""
|
|
396
|
+
Load the default configuration
|
|
397
|
+
|
|
398
|
+
This will load the configuration from the default configuration location,
|
|
399
|
+
which is typically the user's configuration directory.
|
|
400
|
+
This location can be overridden by setting the `REF_CONFIGURATION` environment variable.
|
|
401
|
+
|
|
402
|
+
Returns
|
|
403
|
+
-------
|
|
404
|
+
:
|
|
405
|
+
The default configuration
|
|
406
|
+
"""
|
|
407
|
+
root = env.path("REF_CONFIGURATION")
|
|
408
|
+
path_to_load = root / config_filename
|
|
409
|
+
|
|
410
|
+
logger.debug(f"Loading default configuration from {path_to_load}")
|
|
411
|
+
return cls.load(path_to_load)
|
|
412
|
+
|
|
413
|
+
def dumps(self, defaults: bool = True) -> str:
|
|
414
|
+
"""
|
|
415
|
+
Dump the configuration to a TOML string
|
|
416
|
+
|
|
417
|
+
Parameters
|
|
418
|
+
----------
|
|
419
|
+
defaults
|
|
420
|
+
If True, include default values in the output
|
|
421
|
+
|
|
422
|
+
Returns
|
|
423
|
+
-------
|
|
424
|
+
:
|
|
425
|
+
The configuration as a TOML string
|
|
426
|
+
"""
|
|
427
|
+
return self.dump(defaults).as_string()
|
|
428
|
+
|
|
429
|
+
def dump(
|
|
430
|
+
self,
|
|
431
|
+
defaults: bool = True,
|
|
432
|
+
) -> TOMLDocument:
|
|
433
|
+
"""
|
|
434
|
+
Dump the configuration to a TOML document
|
|
435
|
+
|
|
436
|
+
Parameters
|
|
437
|
+
----------
|
|
438
|
+
defaults
|
|
439
|
+
If True, include default values in the output
|
|
440
|
+
|
|
441
|
+
Returns
|
|
442
|
+
-------
|
|
443
|
+
:
|
|
444
|
+
The configuration as a TOML document
|
|
445
|
+
"""
|
|
446
|
+
if defaults:
|
|
447
|
+
converter = _converter_defaults
|
|
448
|
+
else:
|
|
449
|
+
converter = _converter_no_defaults
|
|
450
|
+
dump = converter.unstructure(self)
|
|
451
|
+
if not defaults:
|
|
452
|
+
_pop_empty(dump)
|
|
453
|
+
doc = TOMLDocument()
|
|
454
|
+
doc.update(dump)
|
|
455
|
+
return doc
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _make_converter(omit_default: bool, forbid_extra_keys: bool) -> Converter:
|
|
459
|
+
conv = Converter(omit_if_default=omit_default, forbid_extra_keys=forbid_extra_keys)
|
|
460
|
+
conv.register_unstructure_hook(Path, str)
|
|
461
|
+
conv.register_unstructure_hook(
|
|
462
|
+
Config,
|
|
463
|
+
make_dict_unstructure_fn(
|
|
464
|
+
Config,
|
|
465
|
+
conv,
|
|
466
|
+
_raw=override(omit=True),
|
|
467
|
+
_config_file=override(omit=True),
|
|
468
|
+
),
|
|
469
|
+
)
|
|
470
|
+
return conv
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
_converter_defaults = _make_converter(omit_default=False, forbid_extra_keys=True)
|
|
474
|
+
_converter_defaults_relaxed = _make_converter(omit_default=False, forbid_extra_keys=False)
|
|
475
|
+
_converter_no_defaults = _make_converter(omit_default=True, forbid_extra_keys=True)
|
climate_ref/constants.py
ADDED
climate_ref/database.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Database adapter layer
|
|
2
|
+
|
|
3
|
+
This module provides a database adapter layer that abstracts the database connection and migrations.
|
|
4
|
+
This allows us to easily switch between different database backends,
|
|
5
|
+
and to run migrations when the database is loaded.
|
|
6
|
+
|
|
7
|
+
The `Database` class is the main entry point for interacting with the database.
|
|
8
|
+
It provides a session object that can be used to interact with the database and run queries.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import importlib.resources
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
from urllib import parse as urlparse
|
|
15
|
+
|
|
16
|
+
import alembic.command
|
|
17
|
+
import sqlalchemy
|
|
18
|
+
from alembic.config import Config as AlembicConfig
|
|
19
|
+
from alembic.runtime.migration import MigrationContext
|
|
20
|
+
from loguru import logger
|
|
21
|
+
from sqlalchemy.orm import Session
|
|
22
|
+
|
|
23
|
+
from climate_ref.models import MetricValue, Table
|
|
24
|
+
from climate_ref_core.pycmec.controlled_vocabulary import CV
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from climate_ref.config import Config
|
|
28
|
+
|
|
29
|
+
_REMOVED_REVISIONS = [
|
|
30
|
+
"ea2aa1134cb3",
|
|
31
|
+
"4b95a617184e",
|
|
32
|
+
"4a447fbf6d65",
|
|
33
|
+
"c1818a18d87f",
|
|
34
|
+
"6634396f139a",
|
|
35
|
+
"1f5969a92b85",
|
|
36
|
+
"c5de99c14533",
|
|
37
|
+
"e1cdda7dcf1d",
|
|
38
|
+
"904f2f2db24a",
|
|
39
|
+
"6bc6ad5fc5e1",
|
|
40
|
+
"4fc26a7d2d28",
|
|
41
|
+
"4ac252ba38ed",
|
|
42
|
+
]
|
|
43
|
+
"""
|
|
44
|
+
List of revisions that have been deleted
|
|
45
|
+
|
|
46
|
+
If a user's database contains these revisions then they need to delete their database and start again.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _get_database_revision(connection: sqlalchemy.engine.Connection) -> str | None:
|
|
51
|
+
context = MigrationContext.configure(connection)
|
|
52
|
+
current_rev = context.get_current_revision()
|
|
53
|
+
return current_rev
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def validate_database_url(database_url: str) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Validate a database URL
|
|
59
|
+
|
|
60
|
+
We support sqlite databases, and we create the directory if it doesn't exist.
|
|
61
|
+
We may aim to support PostgreSQL databases, but this is currently experimental and untested.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
database_url
|
|
66
|
+
The database URL to validate
|
|
67
|
+
|
|
68
|
+
See [climate_ref.config.DbConfig.database_url][climate_ref.config.DbConfig.database_url]
|
|
69
|
+
for more information on the format of the URL.
|
|
70
|
+
|
|
71
|
+
Raises
|
|
72
|
+
------
|
|
73
|
+
ValueError
|
|
74
|
+
If the database scheme is not supported
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
:
|
|
79
|
+
The validated database URL
|
|
80
|
+
"""
|
|
81
|
+
split_url = urlparse.urlsplit(database_url)
|
|
82
|
+
path = split_url.path[1:]
|
|
83
|
+
|
|
84
|
+
if split_url.scheme == "sqlite":
|
|
85
|
+
if path == ":memory:":
|
|
86
|
+
logger.warning("Using an in-memory database")
|
|
87
|
+
else:
|
|
88
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
elif split_url.scheme == "postgresql":
|
|
90
|
+
# We don't need to do anything special for PostgreSQL
|
|
91
|
+
logger.warning("PostgreSQL support is currently experimental and untested")
|
|
92
|
+
else:
|
|
93
|
+
raise ValueError(f"Unsupported database scheme: {split_url.scheme}")
|
|
94
|
+
|
|
95
|
+
return database_url
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Database:
|
|
99
|
+
"""
|
|
100
|
+
Manage the database connection and migrations
|
|
101
|
+
|
|
102
|
+
The database migrations are optionally run after the connection to the database is established.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(self, url: str) -> None:
|
|
106
|
+
logger.info(f"Connecting to database at {url}")
|
|
107
|
+
self.url = url
|
|
108
|
+
self._engine = sqlalchemy.create_engine(self.url)
|
|
109
|
+
self.session = Session(self._engine)
|
|
110
|
+
|
|
111
|
+
def alembic_config(self, config: "Config") -> AlembicConfig:
|
|
112
|
+
"""
|
|
113
|
+
Get the Alembic configuration object for the database
|
|
114
|
+
|
|
115
|
+
This includes an open connection with the database engine and the REF configuration.
|
|
116
|
+
|
|
117
|
+
Returns
|
|
118
|
+
-------
|
|
119
|
+
:
|
|
120
|
+
The Alembic configuration object that can be used with alembic commands
|
|
121
|
+
"""
|
|
122
|
+
alembic_config_filename = importlib.resources.files("climate_ref") / "alembic.ini"
|
|
123
|
+
if not alembic_config_filename.is_file(): # pragma: no cover
|
|
124
|
+
raise FileNotFoundError(f"{alembic_config_filename} not found")
|
|
125
|
+
|
|
126
|
+
alembic_config = AlembicConfig(str(alembic_config_filename))
|
|
127
|
+
alembic_config.attributes["connection"] = self._engine
|
|
128
|
+
alembic_config.attributes["ref_config"] = config
|
|
129
|
+
|
|
130
|
+
return alembic_config
|
|
131
|
+
|
|
132
|
+
def migrate(self, config: "Config") -> None:
|
|
133
|
+
"""
|
|
134
|
+
Migrate the database to the latest revision
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
config
|
|
139
|
+
REF Configuration
|
|
140
|
+
|
|
141
|
+
This is passed to alembic
|
|
142
|
+
"""
|
|
143
|
+
# Check if the database revision is one of the removed revisions
|
|
144
|
+
# If it is, then we need to delete the database and start again
|
|
145
|
+
with self._engine.connect() as connection:
|
|
146
|
+
current_rev = _get_database_revision(connection)
|
|
147
|
+
logger.debug(f"Current database revision: {current_rev}")
|
|
148
|
+
if current_rev in _REMOVED_REVISIONS:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"Database revision {current_rev!r} has been removed in "
|
|
151
|
+
f"https://github.com/Climate-REF/climate-ref/pull/271. "
|
|
152
|
+
"Please delete your database and start again."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
alembic.command.upgrade(self.alembic_config(config), "heads")
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def from_config(config: "Config", run_migrations: bool = True) -> "Database":
|
|
159
|
+
"""
|
|
160
|
+
Create a Database instance from a Config instance
|
|
161
|
+
|
|
162
|
+
The `REF_DATABASE_URL` environment variable will take preference,
|
|
163
|
+
and override the database URL specified in the config.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
config
|
|
168
|
+
The Config instance that includes information about where the database is located
|
|
169
|
+
|
|
170
|
+
Returns
|
|
171
|
+
-------
|
|
172
|
+
:
|
|
173
|
+
A new Database instance
|
|
174
|
+
"""
|
|
175
|
+
database_url: str = config.db.database_url
|
|
176
|
+
|
|
177
|
+
database_url = validate_database_url(database_url)
|
|
178
|
+
|
|
179
|
+
cv = CV.load_from_file(config.paths.dimensions_cv)
|
|
180
|
+
db = Database(database_url)
|
|
181
|
+
|
|
182
|
+
if run_migrations:
|
|
183
|
+
# Run any outstanding migrations
|
|
184
|
+
# This also adds any diagnostic value columns to the DB if they don't exist
|
|
185
|
+
db.migrate(config)
|
|
186
|
+
# Register the CV dimensions with the MetricValue model
|
|
187
|
+
# This will add new columns to the db if the CVs have changed
|
|
188
|
+
MetricValue.register_cv_dimensions(cv)
|
|
189
|
+
|
|
190
|
+
return db
|
|
191
|
+
|
|
192
|
+
def get_or_create(
|
|
193
|
+
self, model: type[Table], defaults: dict[str, Any] | None = None, **kwargs: Any
|
|
194
|
+
) -> tuple[Table, bool]:
|
|
195
|
+
"""
|
|
196
|
+
Get or create an instance of a model
|
|
197
|
+
|
|
198
|
+
This doesn't commit the transaction,
|
|
199
|
+
so you will need to call `session.commit()` after this method
|
|
200
|
+
or use a transaction context manager.
|
|
201
|
+
|
|
202
|
+
Parameters
|
|
203
|
+
----------
|
|
204
|
+
model
|
|
205
|
+
The model to get or create
|
|
206
|
+
defaults
|
|
207
|
+
Default values to use when creating a new instance
|
|
208
|
+
kwargs
|
|
209
|
+
The filter parameters to use when querying for an instance
|
|
210
|
+
|
|
211
|
+
Returns
|
|
212
|
+
-------
|
|
213
|
+
:
|
|
214
|
+
A tuple containing the instance and a boolean indicating if the instance was created
|
|
215
|
+
"""
|
|
216
|
+
instance = self.session.query(model).filter_by(**kwargs).first()
|
|
217
|
+
if instance:
|
|
218
|
+
return instance, False
|
|
219
|
+
else:
|
|
220
|
+
params = {**kwargs, **(defaults or {})}
|
|
221
|
+
instance = model(**params)
|
|
222
|
+
self.session.add(instance)
|
|
223
|
+
return instance, True
|