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.
- climate_ref_core/__init__.py +7 -0
- climate_ref_core/constraints.py +363 -0
- climate_ref_core/dataset_registry.py +158 -0
- climate_ref_core/datasets.py +157 -0
- climate_ref_core/diagnostics.py +549 -0
- climate_ref_core/env.py +35 -0
- climate_ref_core/exceptions.py +48 -0
- climate_ref_core/executor.py +96 -0
- climate_ref_core/logging.py +146 -0
- climate_ref_core/providers.py +418 -0
- climate_ref_core/py.typed +0 -0
- climate_ref_core/pycmec/README.md +1 -0
- climate_ref_core/pycmec/__init__.py +3 -0
- climate_ref_core/pycmec/controlled_vocabulary.py +175 -0
- climate_ref_core/pycmec/cv_cmip7_aft.yaml +44 -0
- climate_ref_core/pycmec/metric.py +437 -0
- climate_ref_core/pycmec/output.py +207 -0
- climate_ref_core-0.5.0.dist-info/METADATA +63 -0
- climate_ref_core-0.5.0.dist-info/RECORD +22 -0
- climate_ref_core-0.5.0.dist-info/WHEEL +4 -0
- climate_ref_core-0.5.0.dist-info/licenses/LICENCE +201 -0
- climate_ref_core-0.5.0.dist-info/licenses/NOTICE +3 -0
|
@@ -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)
|