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
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)
@@ -0,0 +1,8 @@
1
+ """
2
+ Constants used by the REF
3
+ """
4
+
5
+ config_filename = "ref.toml"
6
+ """
7
+ Default name of the configuration file
8
+ """
@@ -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
@@ -0,0 +1,2 @@
1
+ obs4REF/MOHC/HadISST-1-1/mon/ts/gn/v20210727/ts_mon_HadISST-1-1_PCMDI_gn_187001-201907.nc md5:99c8691e0f615dc4d79b4fb5e926cc76
2
+ obs4REF/NOAA-ESRL-PSD/20CR/mon/psl/gn/v20210727/psl_mon_20CR_PCMDI_gn_187101-201212.nc md5:570ce90b3afd1d0b31690ae5dbe32d31