climate-ref-core 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.
@@ -0,0 +1,96 @@
1
+ """
2
+ Executor interface for running diagnostics
3
+ """
4
+
5
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
6
+
7
+ from climate_ref_core.diagnostics import Diagnostic, ExecutionDefinition
8
+ from climate_ref_core.providers import DiagnosticProvider
9
+
10
+ if TYPE_CHECKING:
11
+ from climate_ref.models import Execution
12
+
13
+ EXECUTION_LOG_FILENAME = "out.log"
14
+ """
15
+ Filename for the execution log.
16
+
17
+ This file is written via [climate_ref_core.logging.redirect_logs][].
18
+ """
19
+
20
+
21
+ @runtime_checkable
22
+ class Executor(Protocol):
23
+ """
24
+ An executor is responsible for running a diagnostic asynchronously
25
+
26
+ The diagnostic may be run locally in the same process or in a separate process or container.
27
+
28
+ Notes
29
+ -----
30
+ This is an extremely basic interface and will be expanded in the future, as we figure out
31
+ our requirements.
32
+ """
33
+
34
+ name: str
35
+
36
+ def __init__(self, **kwargs: Any) -> None: ...
37
+
38
+ def run(
39
+ self,
40
+ provider: DiagnosticProvider,
41
+ diagnostic: Diagnostic,
42
+ definition: ExecutionDefinition,
43
+ execution: "Execution | None" = None,
44
+ ) -> None:
45
+ """
46
+ Execute a diagnostic with a given definition
47
+
48
+ No executions are returned from this method,
49
+ as the execution may be performed asynchronously so executions may not be immediately available.
50
+
51
+ /// admonition | Note
52
+ In future, we may return a `Future` object that can be used to retrieve the result,
53
+ but that requires some additional work to implement.
54
+ ///
55
+
56
+ Parameters
57
+ ----------
58
+ provider
59
+ Provider of the diagnostic
60
+ diagnostic
61
+ Diagnostic to run
62
+ definition
63
+ Definition of the information needed to execute a diagnostic
64
+
65
+ This definition describes which datasets are required to run the diagnostic and where
66
+ the output should be stored.
67
+ execution
68
+ The execution object to update with the results of the execution.
69
+
70
+ This is a database object that contains the executions of the execution.
71
+ If provided, it will be updated with the executions of the execution.
72
+ This may happen asynchronously, so the executions may not be immediately available.
73
+
74
+ Returns
75
+ -------
76
+ :
77
+ Results from running the diagnostic
78
+ """
79
+ ...
80
+
81
+ def join(self, timeout: float) -> None:
82
+ """
83
+ Wait for all executions to finish
84
+
85
+ If the timeout is reached, the method will return and raise an exception.
86
+
87
+ Parameters
88
+ ----------
89
+ timeout
90
+ Maximum time to wait for all executions to finish in seconds
91
+
92
+ Raises
93
+ ------
94
+ TimeoutError
95
+ If the timeout is reached
96
+ """
@@ -0,0 +1,146 @@
1
+ """
2
+ Logging utilities
3
+
4
+ The REF uses [loguru](https://loguru.readthedocs.io/en/stable/), a simple logging framework
5
+ """
6
+
7
+ import contextlib
8
+ import inspect
9
+ import logging
10
+ import sys
11
+ from collections.abc import Generator
12
+ from typing import Any
13
+
14
+ import pooch
15
+ from loguru import logger
16
+ from rich.pretty import pretty_repr
17
+
18
+ from climate_ref_core.diagnostics import ExecutionDefinition
19
+ from climate_ref_core.executor import EXECUTION_LOG_FILENAME
20
+
21
+
22
+ class _InterceptHandler(logging.Handler):
23
+ def emit(self, record: logging.LogRecord) -> None:
24
+ # Get corresponding Loguru level if it exists.
25
+ level: str | int
26
+ try:
27
+ level = logger.level(record.levelname).name
28
+ except ValueError: # pragma: no cover
29
+ level = record.levelno
30
+
31
+ # Find caller from where originated the logged message.
32
+ frame, depth = inspect.currentframe(), 0
33
+ while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__):
34
+ frame = frame.f_back
35
+ depth += 1
36
+
37
+ logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
38
+
39
+
40
+ def capture_logging() -> None:
41
+ """
42
+ Capture logging from the standard library and redirect it to Loguru
43
+
44
+ Note that this replaces the root logger, so any other handlers attached to it will be removed.
45
+ """
46
+ # Pooch adds a handler to its own logger which circumvents the REF logger
47
+ pooch.get_logger().handlers.clear()
48
+ pooch.get_logger().addHandler(_InterceptHandler())
49
+
50
+ logging.basicConfig(handlers=[_InterceptHandler()], level=0, force=True)
51
+
52
+ # Disable some overly verbose logs
53
+ logger.disable("matplotlib.colorbar")
54
+ logger.disable("matplotlib.ticker")
55
+ logger.disable("matplotlib.font_manager")
56
+ logger.disable("pyproj.transformer")
57
+ logger.disable("pint.facets.plain.registry")
58
+
59
+
60
+ def add_log_handler(**kwargs: Any) -> None:
61
+ """
62
+ Add a log sink to the logger to capture logs.
63
+
64
+ This is useful for testing purposes, to ensure that logs are captured correctly.
65
+ """
66
+ if hasattr(logger, "default_handler_id"):
67
+ raise AssertionError("The default log handler has already been created")
68
+
69
+ kwargs.setdefault("sink", sys.stderr)
70
+
71
+ handled_id = logger.add(**kwargs)
72
+
73
+ # Track the current handler via custom attributes on the logger
74
+ # This is a bit of a workaround because of loguru's super slim API that doesn't allow for
75
+ # modificiation of existing handlers.
76
+ logger.default_handler_id = handled_id # type: ignore[attr-defined]
77
+ logger.default_handler_kwargs = kwargs # type: ignore[attr-defined]
78
+
79
+ capture_logging()
80
+
81
+
82
+ def remove_log_handler() -> None:
83
+ """
84
+ Remove the default log handler from the logger.
85
+
86
+ This is useful for cleaning up after tests or when changing logging configurations.
87
+ The previously used logger kwargs are kept in `logger.default_handler_kwargs` if the
88
+ logger should be readded later
89
+ """
90
+ if hasattr(logger, "default_handler_id"):
91
+ logger.remove(logger.default_handler_id)
92
+ del logger.default_handler_id
93
+ else:
94
+ raise AssertionError("No default log handler to remove.")
95
+
96
+
97
+ @contextlib.contextmanager
98
+ def redirect_logs(definition: ExecutionDefinition, log_level: str) -> Generator[None, None, None]:
99
+ """
100
+ Temporarily redirect log output to a file.
101
+
102
+ This also writes some common log messages
103
+
104
+ Parameters
105
+ ----------
106
+ definition
107
+ Diagnostic definition to capture logging for
108
+
109
+ log_level
110
+ Log level as a string e.g. INFO, WARNING, DEBUG.
111
+ This log level will dictate what logs will be sent to disk
112
+ The logger will also be reset to this level after leaving the context manager.
113
+
114
+ """
115
+ app_logger_configured = hasattr(logger, "default_handler_id")
116
+
117
+ # Remove existing default log handler
118
+ # This swallows the logs from the app logger
119
+ # If the app logger hasn't been configured yet, we don't need to remove it,
120
+ # as logs will also be written to the console as loguru adds a stderr handler by default
121
+ if app_logger_configured:
122
+ remove_log_handler()
123
+
124
+ # Add a new log handler for the execution log
125
+ output_file = definition.output_directory / EXECUTION_LOG_FILENAME
126
+ file_handler_id = logger.add(output_file, level=log_level, colorize=False)
127
+ capture_logging()
128
+
129
+ logger.info(f"Running definition {pretty_repr(definition)}")
130
+ try:
131
+ yield
132
+ except:
133
+ logger.exception("Execution failed")
134
+ raise
135
+ finally:
136
+ logger.info(f"Diagnostic execution complete. Results available in {definition.output_fragment()}")
137
+
138
+ # Reset the logger to the default
139
+ logger.remove(file_handler_id)
140
+
141
+ # We only re-add the app handler if it was configured before
142
+ if app_logger_configured:
143
+ add_log_handler(**logger.default_handler_kwargs) # type: ignore[attr-defined]
144
+
145
+
146
+ __all__ = ["add_log_handler", "capture_logging", "logger", "redirect_logs"]
@@ -0,0 +1,418 @@
1
+ """
2
+ Interface for declaring a diagnostic provider.
3
+
4
+ This defines how diagnostic packages interoperate with the REF framework.
5
+ Each diagnostic package may contain multiple diagnostics.
6
+
7
+ Each diagnostic package must implement the `DiagnosticProvider` interface.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime
13
+ import hashlib
14
+ import importlib.resources
15
+ import os
16
+ import stat
17
+ import subprocess
18
+ from abc import abstractmethod
19
+ from collections.abc import Iterable
20
+ from contextlib import AbstractContextManager
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING
23
+
24
+ import requests
25
+ from loguru import logger
26
+
27
+ from climate_ref_core.diagnostics import Diagnostic
28
+ from climate_ref_core.exceptions import InvalidDiagnosticException, InvalidProviderException
29
+
30
+ if TYPE_CHECKING:
31
+ from climate_ref.config import Config
32
+
33
+
34
+ def _slugify(value: str) -> str:
35
+ """
36
+ Slugify a string.
37
+
38
+ Parameters
39
+ ----------
40
+ value : str
41
+ String to slugify.
42
+
43
+ Returns
44
+ -------
45
+ str
46
+ Slugified string.
47
+ """
48
+ return value.lower().replace(" ", "-")
49
+
50
+
51
+ class DiagnosticProvider:
52
+ """
53
+ The interface for registering and running diagnostics.
54
+
55
+ Each package that provides diagnostics must implement this interface.
56
+ """
57
+
58
+ def __init__(self, name: str, version: str, slug: str | None = None) -> None:
59
+ self.name = name
60
+ self.slug = slug or _slugify(name)
61
+ self.version = version
62
+
63
+ self._diagnostics: dict[str, Diagnostic] = {}
64
+
65
+ def __repr__(self) -> str:
66
+ return f"{self.__class__.__name__}(name={self.name!r}, version={self.version!r})"
67
+
68
+ def configure(self, config: Config) -> None:
69
+ """
70
+ Configure the provider.
71
+
72
+ Parameters
73
+ ----------
74
+ config :
75
+ A configuration.
76
+ """
77
+
78
+ def diagnostics(self) -> list[Diagnostic]:
79
+ """
80
+ Iterate over the available diagnostics for the provider.
81
+
82
+ Returns
83
+ -------
84
+ :
85
+ Iterator over the currently registered diagnostics.
86
+ """
87
+ return list(self._diagnostics.values())
88
+
89
+ def __len__(self) -> int:
90
+ return len(self._diagnostics)
91
+
92
+ def register(self, diagnostic: Diagnostic) -> None:
93
+ """
94
+ Register a diagnostic with the manager.
95
+
96
+ Parameters
97
+ ----------
98
+ diagnostic :
99
+ The diagnostic to register.
100
+ """
101
+ if not isinstance(diagnostic, Diagnostic):
102
+ raise InvalidDiagnosticException(
103
+ diagnostic, "Diagnostics must be an instance of the 'Diagnostic' class"
104
+ )
105
+ diagnostic.provider = self
106
+ self._diagnostics[diagnostic.slug.lower()] = diagnostic
107
+
108
+ def get(self, slug: str) -> Diagnostic:
109
+ """
110
+ Get a diagnostic by name.
111
+
112
+ Parameters
113
+ ----------
114
+ slug :
115
+ Name of the diagnostic (case-sensitive).
116
+
117
+ Raises
118
+ ------
119
+ KeyError
120
+ If the diagnostic with the given name is not found.
121
+
122
+ Returns
123
+ -------
124
+ Diagnostic
125
+ The requested diagnostic.
126
+ """
127
+ return self._diagnostics[slug.lower()]
128
+
129
+
130
+ def import_provider(fqn: str) -> DiagnosticProvider:
131
+ """
132
+ Import a provider by name
133
+
134
+ Parameters
135
+ ----------
136
+ fqn
137
+ Full package and attribute name of the provider to import
138
+
139
+ For example: `climate_ref_example.provider` will use the `provider` attribute from the
140
+ `climate_ref_example` package.
141
+
142
+ If only a package name is provided, the default attribute name is `provider`.
143
+
144
+ Raises
145
+ ------
146
+ InvalidProviderException
147
+ If the provider cannot be imported
148
+
149
+ If the provider isn't a valid `DiagnosticProvider`.
150
+
151
+ Returns
152
+ -------
153
+ :
154
+ DiagnosticProvider instance
155
+ """
156
+ if "." in fqn:
157
+ module, name = fqn.rsplit(".", 1)
158
+ else:
159
+ module = fqn
160
+ name = "provider"
161
+
162
+ try:
163
+ imp = importlib.import_module(module)
164
+ provider = getattr(imp, name)
165
+ if not isinstance(provider, DiagnosticProvider):
166
+ raise InvalidProviderException(fqn, f"Expected DiagnosticProvider, got {type(provider)}")
167
+ return provider
168
+ except ModuleNotFoundError:
169
+ logger.error(f"Module '{fqn}' not found")
170
+ raise InvalidProviderException(fqn, f"Module '{module}' not found")
171
+ except AttributeError:
172
+ logger.error(f"Provider '{fqn}' not found")
173
+ raise InvalidProviderException(fqn, f"Provider '{name}' not found in {module}")
174
+
175
+
176
+ class CommandLineDiagnosticProvider(DiagnosticProvider):
177
+ """
178
+ A provider for diagnostics that can be run from the command line.
179
+ """
180
+
181
+ @abstractmethod
182
+ def run(self, cmd: Iterable[str]) -> None:
183
+ """
184
+ Return the arguments for the command to run.
185
+ """
186
+
187
+
188
+ MICROMAMBA_EXE_URL = (
189
+ "https://github.com/mamba-org/micromamba-releases/releases/latest/download/micromamba-{platform}-{arch}"
190
+ )
191
+ """The URL to download the micromamba executable from."""
192
+
193
+
194
+ MICROMAMBA_MAX_AGE = datetime.timedelta(days=7)
195
+ """Do not update if the micromamba executable is younger than this age."""
196
+
197
+
198
+ def _get_micromamba_url() -> str:
199
+ """
200
+ Build a platform specific URL from which to download micromamba.
201
+
202
+ Based on the script at: https://micro.mamba.pm/install.sh
203
+
204
+ """
205
+ sysname = os.uname().sysname
206
+ machine = os.uname().machine
207
+
208
+ if sysname == "Linux":
209
+ platform = "linux"
210
+ elif sysname == "Darwin":
211
+ platform = "osx"
212
+ elif "NT" in sysname:
213
+ platform = "win"
214
+ else:
215
+ platform = sysname
216
+
217
+ arch = machine if machine in {"aarch64", "ppc64le", "arm64"} else "64"
218
+
219
+ supported = {
220
+ "linux-aarch64",
221
+ "linux-ppc64le",
222
+ "linux-64",
223
+ "osx-arm64",
224
+ "osx-64",
225
+ "win-64",
226
+ }
227
+ if f"{platform}-{arch}" not in supported:
228
+ msg = "Failed to detect your platform. Please set MICROMAMBA_EXE_URL to a valid location."
229
+ raise ValueError(msg)
230
+
231
+ return MICROMAMBA_EXE_URL.format(platform=platform, arch=arch)
232
+
233
+
234
+ class CondaDiagnosticProvider(CommandLineDiagnosticProvider):
235
+ """
236
+ A provider for diagnostics that can be run from the command line in a conda environment.
237
+ """
238
+
239
+ def __init__(
240
+ self,
241
+ name: str,
242
+ version: str,
243
+ slug: str | None = None,
244
+ repo: str | None = None,
245
+ tag_or_commit: str | None = None,
246
+ ) -> None:
247
+ super().__init__(name, version, slug)
248
+ self._conda_exe: Path | None = None
249
+ self._prefix: Path | None = None
250
+ self.url = f"git+{repo}@{tag_or_commit}" if repo and tag_or_commit else None
251
+
252
+ @property
253
+ def prefix(self) -> Path:
254
+ """Path where conda environments are stored."""
255
+ if not isinstance(self._prefix, Path):
256
+ msg = (
257
+ "No prefix for conda environments configured. Please use the "
258
+ "configure method to configure the provider or assign a value "
259
+ "to prefix directly."
260
+ )
261
+ raise ValueError(msg)
262
+ return self._prefix
263
+
264
+ @prefix.setter
265
+ def prefix(self, path: Path) -> None:
266
+ self._prefix = path
267
+
268
+ def configure(self, config: Config) -> None:
269
+ """Configure the provider."""
270
+ self.prefix = config.paths.software / "conda"
271
+
272
+ def _install_conda(self, update: bool) -> Path:
273
+ """Install micromamba in a temporary location.
274
+
275
+ Parameters
276
+ ----------
277
+ update:
278
+ Update the micromamba executable if it is older than a week.
279
+
280
+ Returns
281
+ -------
282
+ The path to the executable.
283
+
284
+ """
285
+ conda_exe = self.prefix / "micromamba"
286
+
287
+ if conda_exe.exists() and update:
288
+ # Only update if the executable is older than `MICROMAMBA_MAX_AGE`.
289
+ creation_time = datetime.datetime.fromtimestamp(conda_exe.stat().st_ctime)
290
+ age = datetime.datetime.now() - creation_time
291
+ if age < MICROMAMBA_MAX_AGE:
292
+ update = False
293
+
294
+ if not conda_exe.exists() or update:
295
+ logger.info("Installing conda")
296
+ self.prefix.mkdir(parents=True, exist_ok=True)
297
+ response = requests.get(_get_micromamba_url(), timeout=120)
298
+ response.raise_for_status()
299
+ with conda_exe.open(mode="wb") as file:
300
+ file.write(response.content)
301
+ conda_exe.chmod(stat.S_IRWXU)
302
+ logger.info("Successfully installed conda.")
303
+
304
+ return conda_exe
305
+
306
+ def get_conda_exe(self, update: bool = False) -> Path:
307
+ """
308
+ Get the path to a conda executable.
309
+ """
310
+ if self._conda_exe is None:
311
+ self._conda_exe = self._install_conda(update)
312
+ return self._conda_exe
313
+
314
+ def get_environment_file(self) -> AbstractContextManager[Path]:
315
+ """
316
+ Return a context manager that provides the environment file as a Path.
317
+ """
318
+ # Because providers are instances, we have no way of retrieving the
319
+ # module in which they are created, so get the information from the
320
+ # first registered diagnostic instead.
321
+ diagnostics = self.diagnostics()
322
+ if len(diagnostics) == 0:
323
+ msg = "Unable to determine the provider module, please register a diagnostic first."
324
+ raise ValueError(msg)
325
+ module = diagnostics[0].__module__.split(".")[0]
326
+ lockfile = importlib.resources.files(module).joinpath("requirements").joinpath("conda-lock.yml")
327
+ return importlib.resources.as_file(lockfile)
328
+
329
+ @property
330
+ def env_path(self) -> Path:
331
+ """
332
+ A unique path for storing the conda environment.
333
+ """
334
+ with self.get_environment_file() as file:
335
+ suffix = hashlib.sha1(file.read_bytes(), usedforsecurity=False)
336
+ if self.url is not None:
337
+ suffix.update(bytes(self.url, encoding="utf-8"))
338
+ return self.prefix / f"{self.slug}-{suffix.hexdigest()}"
339
+
340
+ def create_env(self) -> None:
341
+ """
342
+ Create a conda environment.
343
+ """
344
+ logger.debug(f"Attempting to create environment at {self.env_path}")
345
+ if self.env_path.exists():
346
+ logger.info(f"Environment at {self.env_path} already exists, skipping.")
347
+ return
348
+
349
+ conda_exe = f"{self.get_conda_exe(update=True)}"
350
+ with self.get_environment_file() as file:
351
+ cmd = [
352
+ conda_exe,
353
+ "create",
354
+ "--yes",
355
+ "--file",
356
+ f"{file}",
357
+ "--prefix",
358
+ f"{self.env_path}",
359
+ ]
360
+ logger.debug(f"Running {' '.join(cmd)}")
361
+ subprocess.run(cmd, check=True) # noqa: S603
362
+
363
+ if self.url is not None:
364
+ logger.info(f"Installing development version of {self.slug} from {self.url}")
365
+ cmd = [
366
+ conda_exe,
367
+ "run",
368
+ "--prefix",
369
+ f"{self.env_path}",
370
+ "pip",
371
+ "install",
372
+ "--no-deps",
373
+ self.url,
374
+ ]
375
+ logger.debug(f"Running {' '.join(cmd)}")
376
+ subprocess.run(cmd, check=True) # noqa: S603
377
+
378
+ def run(self, cmd: Iterable[str]) -> None:
379
+ """
380
+ Run a command.
381
+
382
+ Parameters
383
+ ----------
384
+ cmd
385
+ The command to run.
386
+
387
+ Raises
388
+ ------
389
+ subprocess.CalledProcessError
390
+ If the command fails
391
+
392
+ """
393
+ self.create_env()
394
+
395
+ cmd = [
396
+ f"{self.get_conda_exe(update=False)}",
397
+ "run",
398
+ "--prefix",
399
+ f"{self.env_path}",
400
+ *cmd,
401
+ ]
402
+ logger.info(f"Running '{' '.join(cmd)}'")
403
+ try:
404
+ # This captures the log output until the execution is complete
405
+ # We could poll using `subprocess.Popen` if we want something more responsive
406
+ res = subprocess.run( # noqa: S603
407
+ cmd,
408
+ check=True,
409
+ stdout=subprocess.PIPE,
410
+ stderr=subprocess.STDOUT,
411
+ text=True,
412
+ )
413
+ logger.info("Command output: \n" + res.stdout)
414
+ logger.info("Command execution successful")
415
+ except subprocess.CalledProcessError as e:
416
+ logger.error(f"Failed to run {cmd}")
417
+ logger.error(e.stdout)
418
+ raise e
File without changes
@@ -0,0 +1 @@
1
+ CMEC python implementation (pycmec)
@@ -0,0 +1,3 @@
1
+ """
2
+ CMEC python package
3
+ """