anemoi-utils 0.4.26__py3-none-any.whl → 0.4.28__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.

Potentially problematic release.


This version of anemoi-utils might be problematic. Click here for more details.

anemoi/utils/_version.py CHANGED
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.4.26'
21
- __version_tuple__ = version_tuple = (0, 4, 26)
20
+ __version__ = version = '0.4.28'
21
+ __version_tuple__ = version_tuple = (0, 4, 28)
@@ -0,0 +1,8 @@
1
+ # (C) Copyright 2024 Anemoi contributors.
2
+ #
3
+ # This software is licensed under the terms of the Apache Licence Version 2.0
4
+ # which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
5
+ #
6
+ # In applying this licence, ECMWF does not waive the privileges and immunities
7
+ # granted to it by virtue of its status as an intergovernmental organisation
8
+ # nor does it submit to any jurisdiction.
@@ -0,0 +1,246 @@
1
+ # (C) Copyright 2024 Anemoi contributors.
2
+ #
3
+ # This software is licensed under the terms of the Apache Licence Version 2.0
4
+ # which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
5
+ #
6
+ # In applying this licence, ECMWF does not waive the privileges and immunities
7
+ # granted to it by virtue of its status as an intergovernmental organisation
8
+ # nor does it submit to any jurisdiction.
9
+
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import os
15
+ import time
16
+ from datetime import datetime
17
+ from datetime import timezone
18
+ from functools import wraps
19
+ from getpass import getpass
20
+ from typing import TYPE_CHECKING
21
+
22
+ import requests
23
+ from requests.exceptions import HTTPError
24
+
25
+ from ..config import load_config
26
+ from ..config import save_config
27
+ from ..remote import robust
28
+ from ..timer import Timer
29
+
30
+ REFRESH_EXPIRE_DAYS = 29
31
+
32
+
33
+ if TYPE_CHECKING:
34
+ from collections.abc import Callable
35
+
36
+
37
+ class TokenAuth:
38
+ """Manage authentication with a keycloak token server."""
39
+
40
+ config_file = "mlflow-token.json"
41
+
42
+ def __init__(
43
+ self,
44
+ url: str,
45
+ enabled: bool = True,
46
+ target_env_var: str = "MLFLOW_TRACKING_TOKEN",
47
+ ) -> None:
48
+ """Initialise the token authentication object.
49
+
50
+ Parameters
51
+ ----------
52
+ url : str
53
+ URL of the authentication server.
54
+ enabled : bool, optional
55
+ Set this to False to turn off authentication, by default True
56
+ target_env_var : str, optional
57
+ The environment variable to store the access token in after authenticating,
58
+ by default `MLFLOW_TRACKING_TOKEN`
59
+
60
+ """
61
+ self.url = url
62
+ self.target_env_var = target_env_var
63
+ self._enabled = enabled
64
+
65
+ config = self.load_config()
66
+
67
+ self._refresh_token = config.get("refresh_token")
68
+ self.refresh_expires = config.get("refresh_expires", 0)
69
+ self.access_token = None
70
+ self.access_expires = 0
71
+
72
+ # the command line tool adds a default handler to the root logger on runtime,
73
+ # so we init our logger here (on runtime, not on import) to avoid duplicate handlers
74
+ self.log = logging.getLogger(__name__)
75
+
76
+ def __call__(self) -> None:
77
+ self.authenticate()
78
+
79
+ @property
80
+ def refresh_token(self) -> str:
81
+ return self._refresh_token
82
+
83
+ @refresh_token.setter
84
+ def refresh_token(self, value: str) -> None:
85
+ self._refresh_token = value
86
+ self.refresh_expires = time.time() + (REFRESH_EXPIRE_DAYS * 86400) # 86400 seconds in a day
87
+
88
+ @staticmethod
89
+ def load_config() -> dict:
90
+ return load_config(TokenAuth.config_file)
91
+
92
+ def enabled(fn: Callable) -> Callable: # noqa: N805
93
+ """Decorator to call or ignore a function based on the `enabled` flag."""
94
+
95
+ @wraps(fn)
96
+ def _wrapper(self: TokenAuth, *args, **kwargs) -> Callable | None:
97
+ if self._enabled:
98
+ return fn(self, *args, **kwargs)
99
+ return None
100
+
101
+ return _wrapper
102
+
103
+ @enabled
104
+ def login(self, force_credentials: bool = False, **kwargs: dict) -> None:
105
+ """Acquire a new refresh token and save it to disk.
106
+
107
+ If an existing valid refresh token is already on disk it will be used.
108
+ If not, or the token has expired, the user will be asked to obtain one from the API.
109
+
110
+ Refresh token expiry time is set in the `REFRESH_EXPIRE_DAYS` constant (default 29 days).
111
+
112
+ This function should be called once, interactively, right before starting a training run.
113
+
114
+ Parameters
115
+ ----------
116
+ force_credentials : bool, optional
117
+ Force a credential login even if a refreh token is available, by default False.
118
+ kwargs : dict
119
+ Additional keyword arguments.
120
+
121
+ Raises
122
+ ------
123
+ RuntimeError
124
+ A new refresh token could not be acquired.
125
+
126
+ """
127
+ del kwargs # unused
128
+ self.log.info("🌐 Logging in to %s", self.url)
129
+ new_refresh_token = None
130
+
131
+ if not force_credentials and self.refresh_token and self.refresh_expires > time.time():
132
+ new_refresh_token = self._token_request(ignore_exc=True).get("refresh_token")
133
+
134
+ if not new_refresh_token:
135
+ self.log.info("📝 Please obtain a seed refresh token from %s/seed", self.url)
136
+ self.log.info("📝 and paste it here (you will not see the output, just press enter after pasting):")
137
+ self.refresh_token = getpass("Refresh Token: ")
138
+
139
+ # perform a new refresh token request to check if the seed refresh token is valid
140
+ new_refresh_token = self._token_request().get("refresh_token")
141
+
142
+ if not new_refresh_token:
143
+ msg = "❌ Failed to log in. Please try again."
144
+ raise RuntimeError(msg)
145
+
146
+ self.refresh_token = new_refresh_token
147
+ self.save()
148
+
149
+ self.log.info("✅ Successfully logged in to MLflow. Happy logging!")
150
+
151
+ @enabled
152
+ def authenticate(self, **kwargs: dict) -> None:
153
+ """Check the access token and refresh it if necessary. A new refresh token will also be acquired upon refresh.
154
+
155
+ This requires a valid refresh token to be available, obtained from the `login` method.
156
+
157
+ The access token is stored in memory and in an environment variable.
158
+ If the access token is still valid, this function does nothing.
159
+
160
+ This function should be called before every MLflow API request.
161
+
162
+ Raises
163
+ ------
164
+ RuntimeError
165
+ No refresh token is available or the token request failed.
166
+
167
+ """
168
+ del kwargs # unused
169
+ if self.access_expires > time.time():
170
+ return
171
+
172
+ if not self.refresh_token or self.refresh_expires < time.time():
173
+ msg = "You are not logged in to MLflow. Please log in first."
174
+ raise RuntimeError(msg)
175
+
176
+ with Timer("Access token refreshed", self.log):
177
+ response = self._token_request()
178
+
179
+ self.access_token = response.get("access_token")
180
+ self.access_expires = time.time() + (response.get("expires_in") * 0.7) # bit of buffer
181
+ self.refresh_token = response.get("refresh_token")
182
+
183
+ os.environ[self.target_env_var] = self.access_token
184
+
185
+ @enabled
186
+ def save(self, **kwargs: dict) -> None:
187
+ """Save the latest refresh token to disk."""
188
+ del kwargs # unused
189
+ if not self.refresh_token:
190
+ self.log.warning("No refresh token to save.")
191
+ return
192
+
193
+ config = {
194
+ "url": self.url,
195
+ "refresh_token": self.refresh_token,
196
+ "refresh_expires": self.refresh_expires,
197
+ }
198
+ save_config(self.config_file, config)
199
+
200
+ expire_date = datetime.fromtimestamp(self.refresh_expires, tz=timezone.utc)
201
+ self.log.info(
202
+ "Your MLflow login token is valid until %s UTC",
203
+ expire_date.strftime("%Y-%m-%d %H:%M:%S"),
204
+ )
205
+
206
+ def _token_request(
207
+ self,
208
+ ignore_exc: bool = False,
209
+ ) -> dict:
210
+ path = "refreshtoken"
211
+ payload = {"refresh_token": self.refresh_token}
212
+
213
+ try:
214
+ response = self._request(path, payload)
215
+ except Exception:
216
+ if ignore_exc:
217
+ return {}
218
+ raise
219
+
220
+ return response
221
+
222
+ def _request(self, path: str, payload: dict) -> dict:
223
+
224
+ headers = {
225
+ "Content-Type": "application/x-www-form-urlencoded",
226
+ }
227
+
228
+ try:
229
+ response = robust(requests.post)(
230
+ f"{self.url}/{path}",
231
+ headers=headers,
232
+ json=payload,
233
+ timeout=60,
234
+ )
235
+ response.raise_for_status()
236
+ response_json = response.json()
237
+
238
+ if response_json.get("status", "") != "OK":
239
+ error_description = response_json.get("response", "Error acquiring token.")
240
+ msg = f"❌ {error_description}"
241
+ raise RuntimeError(msg)
242
+
243
+ return response_json["response"]
244
+ except HTTPError:
245
+ self.log.exception("HTTP error occurred")
246
+ raise
@@ -0,0 +1,76 @@
1
+ # (C) Copyright 2024 Anemoi contributors.
2
+ #
3
+ # This software is licensed under the terms of the Apache Licence Version 2.0
4
+ # which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
5
+ #
6
+ # In applying this licence, ECMWF does not waive the privileges and immunities
7
+ # granted to it by virtue of its status as an intergovernmental organisation
8
+ # nor does it submit to any jurisdiction.
9
+
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ try:
16
+ from mlflow import MlflowClient
17
+ except ImportError:
18
+ raise ImportError(
19
+ "The `mlflow` package is required to use AnemoiMLflowclient. Please install it with `pip install mlflow`."
20
+ )
21
+
22
+ from .auth import TokenAuth
23
+ from .utils import health_check
24
+
25
+
26
+ class AnemoiMlflowClient(MlflowClient):
27
+ """Anemoi extension of the MLflow client with token authentication support."""
28
+
29
+ def __init__(
30
+ self,
31
+ tracking_uri: str,
32
+ *args,
33
+ authentication: bool = False,
34
+ check_health: bool = True,
35
+ **kwargs,
36
+ ) -> None:
37
+ """Behaves like a normal `mlflow.MlflowClient` but with token authentication injected on every call.
38
+
39
+ Parameters
40
+ ----------
41
+ tracking_uri : str
42
+ The URI of the MLflow tracking server.
43
+ authentication : bool, optional
44
+ Enable token authentication, by default False
45
+ check_health : bool, optional
46
+ Check the health of the MLflow server on init, by default True
47
+ *args : Any
48
+ Additional arguments to pass to the MLflow client.
49
+ **kwargs : Any
50
+ Additional keyword arguments to pass to the MLflow client.
51
+
52
+ """
53
+ self.anemoi_auth = TokenAuth(tracking_uri, enabled=authentication)
54
+ if check_health:
55
+ super().__getattribute__("anemoi_auth").authenticate()
56
+ health_check(tracking_uri)
57
+ super().__init__(tracking_uri, *args, **kwargs)
58
+
59
+ def __getattribute__(self, name: str) -> Any:
60
+ """Intercept attribute access and inject authentication."""
61
+ attr = super().__getattribute__(name)
62
+ if callable(attr) and name != "anemoi_auth":
63
+ super().__getattribute__("anemoi_auth").authenticate()
64
+ return attr
65
+
66
+ def login(self, force_credentials: bool = False, **kwargs) -> None:
67
+ """Explicitly log in to the MLflow server by acquiring or refreshing the token.
68
+
69
+ Parameters
70
+ ----------
71
+ force_credentials : bool, optional
72
+ Force a credential login even if a refresh token is available, by default False.
73
+ kwargs : dict
74
+ Additional keyword arguments passed to the underlying TokenAuth.login.
75
+ """
76
+ self.anemoi_auth.login(force_credentials=force_credentials, **kwargs)
@@ -0,0 +1,159 @@
1
+ # (C) Copyright 2024 Anemoi contributors.
2
+ #
3
+ # This software is licensed under the terms of the Apache Licence Version 2.0
4
+ # which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
5
+ #
6
+ # In applying this licence, ECMWF does not waive the privileges and immunities
7
+ # granted to it by virtue of its status as an intergovernmental organisation
8
+ # nor does it submit to any jurisdiction.
9
+ from __future__ import annotations
10
+
11
+ import functools
12
+ import os
13
+ from typing import Any
14
+
15
+ import requests
16
+
17
+ from ..remote import robust
18
+
19
+
20
+ def health_check(tracking_uri: str) -> None:
21
+ """Query the health endpoint of an MLflow server.
22
+
23
+ If the server is not reachable, raise an error and remind the user that authentication may be required.
24
+
25
+ Raises
26
+ ------
27
+ ConnectionError
28
+ If the server is not reachable.
29
+
30
+ """
31
+ token = os.getenv("MLFLOW_TRACKING_TOKEN")
32
+
33
+ headers = {"Authorization": f"Bearer {token}"}
34
+ response = robust(requests.get, retry_after=30, maximum_tries=10)(
35
+ f"{tracking_uri}/health",
36
+ headers=headers,
37
+ timeout=60,
38
+ )
39
+
40
+ if response.text == "OK":
41
+ return
42
+
43
+ error_msg = f"Could not connect to MLflow server at {tracking_uri}. "
44
+ if not token:
45
+ error_msg += "The server may require authentication, did you forget to turn it on?"
46
+ raise ConnectionError(error_msg)
47
+
48
+
49
+ def expand_iterables(
50
+ params: dict[str, Any],
51
+ *,
52
+ size_threshold: int | None = None,
53
+ recursive: bool = True,
54
+ delimiter: str = ".",
55
+ ) -> dict[str, Any]:
56
+ """Expand any iterable values to the form {key.i: value_i}.
57
+
58
+ If expanded will also add {key.all: [value_0, value_1, ...], key.length: len([value_0, value_1, ...])}.
59
+
60
+ If `size_threshold` is not None, expand the iterable only if the length of str(value) is
61
+ greater than `size_threshold`.
62
+
63
+ Parameters
64
+ ----------
65
+ params : dict[str, Any]
66
+ Parameters to be expanded.
67
+ size_threshold : int | None, optional
68
+ Threshold of str(value) to expand iterable at.
69
+ Default is None.
70
+ recursive : bool, optional
71
+ Expand nested dictionaries.
72
+ Default is True.
73
+ delimiter: str, optional
74
+ Delimiter to use for keys.
75
+ Default is ".".
76
+
77
+ Returns
78
+ -------
79
+ dict[str, Any]
80
+ Dictionary with all iterable values expanded.
81
+
82
+ Examples
83
+ --------
84
+ >>> expand_iterables({'a': ['a', 'b', 'c']})
85
+ {'a.0': 'a', 'a.1': 'b', 'a.2': 'c', 'a.all': ['a', 'b', 'c'], 'a.length': 3}
86
+ >>> expand_iterables({'a': {'b': ['a', 'b', 'c']}})
87
+ {'a': {'b.0': 'a', 'b.1': 'b', 'b.2': 'c', 'b.all': ['a', 'b', 'c'], 'b.length': 3}}
88
+ >>> expand_iterables({'a': ['a', 'b', 'c']}, size_threshold=100)
89
+ {'a': ['a', 'b', 'c']}
90
+ >>> expand_iterables({'a': [[0,1,2], 'b', 'c']})
91
+ {'a.0': {0: 0, 1: 1, 2: 2}, 'a.1': 'b', 'a.2': 'c', 'a.all': [[0, 1, 2], 'b', 'c'], 'a.length': 3}
92
+ """
93
+
94
+ def should_be_expanded(x: Any) -> bool:
95
+ return size_threshold is None or len(str(x)) > size_threshold
96
+
97
+ nested_func = functools.partial(expand_iterables, size_threshold=size_threshold, recursive=recursive)
98
+
99
+ def expand(val: dict | list) -> dict[str, Any]:
100
+ if not recursive:
101
+ return val
102
+ if isinstance(val, dict):
103
+ return nested_func(val)
104
+ if isinstance(val, list):
105
+ return nested_func(dict(enumerate(val)))
106
+ return val
107
+
108
+ expanded_params = {}
109
+
110
+ for key, value in params.items():
111
+ if isinstance(value, (list, tuple)):
112
+ if should_be_expanded(value):
113
+ for i, v in enumerate(value):
114
+ expanded_params[f"{key}{delimiter}{i}"] = expand(v)
115
+
116
+ expanded_params[f"{key}{delimiter}all"] = value
117
+ expanded_params[f"{key}{delimiter}length"] = len(value)
118
+ else:
119
+ expanded_params[key] = value
120
+ else:
121
+ expanded_params[key] = expand(value)
122
+ return expanded_params
123
+
124
+
125
+ def clean_config_params(params: dict[str, Any]) -> dict[str, Any]:
126
+ """Clean up params to avoid issues with mlflow.
127
+
128
+ Too many logged params will make the server take longer to render the
129
+ experiment.
130
+
131
+ Parameters
132
+ ----------
133
+ params : dict[str, Any]
134
+ Parameters to clean up.
135
+
136
+ Returns
137
+ -------
138
+ dict[str, Any]
139
+ Cleaned up params ready for MlFlow.
140
+ """
141
+ prefixes_to_remove = [
142
+ "hardware",
143
+ "data",
144
+ "dataloader",
145
+ "model",
146
+ "training",
147
+ "diagnostics",
148
+ "graph",
149
+ "metadata.config",
150
+ "config.dataset.sourcesmetadata.dataset.variables_metadata",
151
+ "metadata.dataset.sources",
152
+ "metadata.dataset.specific",
153
+ "metadata.dataset.variables_metadata",
154
+ ]
155
+
156
+ keys_to_remove = [key for key in params if any(key.startswith(prefix) for prefix in prefixes_to_remove)]
157
+ for key in keys_to_remove:
158
+ del params[key]
159
+ return params
anemoi/utils/remote/s3.py CHANGED
@@ -47,8 +47,8 @@ SECRETS = ["aws_access_key_id", "aws_secret_access_key"]
47
47
  thread_local = threading.local()
48
48
 
49
49
 
50
- def s3_client(bucket: str, *, region: str = None, service: str = "s3") -> Any:
51
- """Get an S3 client for the specified bucket and region.
50
+ def _s3_config(bucket: str, *, region: str = None) -> Any:
51
+ """Get an S3 client config for the specified bucket and region.
52
52
 
53
53
  Parameters
54
54
  ----------
@@ -56,31 +56,15 @@ def s3_client(bucket: str, *, region: str = None, service: str = "s3") -> Any:
56
56
  The name of the S3 bucket.
57
57
  region : str, optional
58
58
  The AWS region of the S3 bucket.
59
- service : str, optional
60
- The AWS service to use, default is "s3".
61
59
 
62
60
  Returns
63
61
  -------
64
62
  Any
65
63
  The S3 client.
66
64
  """
67
- import boto3
68
65
  from botocore import UNSIGNED
69
- from botocore.client import Config
70
66
 
71
- if not hasattr(thread_local, "s3_clients"):
72
- thread_local.s3_clients = {}
73
-
74
- key = f"{bucket}-{region}-{service}"
75
-
76
- if key in thread_local.s3_clients:
77
- return thread_local.s3_clients[key]
78
-
79
- boto3_config = dict(
80
- max_pool_connections=25,
81
- request_checksum_calculation="when_required",
82
- response_checksum_validation="when_required",
83
- )
67
+ boto3_config = {}
84
68
 
85
69
  if region:
86
70
  # This is using AWS
@@ -129,6 +113,85 @@ def s3_client(bucket: str, *, region: str = None, service: str = "s3") -> Any:
129
113
  boto3_config.update(options["config"])
130
114
  del options["config"]
131
115
 
116
+ def _(options):
117
+
118
+ def __(k, v):
119
+ if k in SECRETS:
120
+ return "***"
121
+ return v
122
+
123
+ if isinstance(options, dict):
124
+ return {k: __(k, v) for k, v in options.items()}
125
+
126
+ if isinstance(options, list):
127
+ return [_(o) for o in options]
128
+
129
+ return options
130
+
131
+ LOG.debug(f"Using S3 options: {_(options)}")
132
+
133
+ return boto3_config, options
134
+
135
+
136
+ def s3_options(bucket: str, *, region: str = None, service: str = "s3") -> dict:
137
+ """Get the S3 configuration for the specified bucket and region.
138
+
139
+ Parameters
140
+ ----------
141
+ bucket : str
142
+ The name of the S3 bucket.
143
+ region : str, optional
144
+ The AWS region of the S3 bucket.
145
+ service : str, optional
146
+ The AWS service to use, default is "s3".
147
+
148
+ Returns
149
+ -------
150
+ dict
151
+ The S3 configuration.
152
+ """
153
+ _, options = _s3_config(bucket, region=region)
154
+ return options
155
+
156
+
157
+ def s3_client(bucket: str, *, region: str = None, service: str = "s3") -> Any:
158
+ """Get an S3 client for the specified bucket and region.
159
+
160
+ Parameters
161
+ ----------
162
+ bucket : str
163
+ The name of the S3 bucket.
164
+ region : str, optional
165
+ The AWS region of the S3 bucket.
166
+ service : str, optional
167
+ The AWS service to use, default is "s3".
168
+
169
+ Returns
170
+ -------
171
+ Any
172
+ The S3 client.
173
+ """
174
+ import boto3
175
+ from botocore.client import Config
176
+
177
+ if not hasattr(thread_local, "s3_clients"):
178
+ thread_local.s3_clients = {}
179
+
180
+ key = f"{bucket}-{region}-{service}"
181
+
182
+ if key in thread_local.s3_clients:
183
+ return thread_local.s3_clients[key]
184
+
185
+ boto3_config, options = _s3_config(bucket, region=region)
186
+
187
+ boto3_config.update(
188
+ dict(
189
+ max_pool_connections=25,
190
+ request_checksum_calculation="when_required",
191
+ response_checksum_validation="when_required",
192
+ )
193
+ )
194
+
132
195
  options["config"] = Config(**boto3_config)
133
196
 
134
197
  def _(options):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anemoi-utils
3
- Version: 0.4.26
3
+ Version: 0.4.28
4
4
  Summary: A package to hold various functions to support training of ML models on ECMWF data.
5
5
  Author-email: "European Centre for Medium-Range Weather Forecasts (ECMWF)" <software.support@ecmwf.int>
6
6
  License: Apache License
@@ -236,7 +236,7 @@ Requires-Dist: rich
236
236
  Requires-Dist: tomli; python_version < "3.11"
237
237
  Requires-Dist: tqdm
238
238
  Provides-Extra: all
239
- Requires-Dist: anemoi-utils[grib,provenance,s3,text]; extra == "all"
239
+ Requires-Dist: anemoi-utils[grib,mlflow,provenance,s3,text]; extra == "all"
240
240
  Provides-Extra: dev
241
241
  Requires-Dist: anemoi-utils[all,docs,tests]; extra == "dev"
242
242
  Provides-Extra: docs
@@ -250,6 +250,9 @@ Requires-Dist: sphinx-rtd-theme; extra == "docs"
250
250
  Requires-Dist: termcolor; extra == "docs"
251
251
  Provides-Extra: grib
252
252
  Requires-Dist: requests; extra == "grib"
253
+ Provides-Extra: mlflow
254
+ Requires-Dist: mlflow>=2.11.1; extra == "mlflow"
255
+ Requires-Dist: requests; extra == "mlflow"
253
256
  Provides-Extra: provenance
254
257
  Requires-Dist: gitpython; extra == "provenance"
255
258
  Requires-Dist: nvsmi; extra == "provenance"
@@ -257,6 +260,7 @@ Provides-Extra: s3
257
260
  Requires-Dist: boto3>1.36; extra == "s3"
258
261
  Provides-Extra: tests
259
262
  Requires-Dist: pytest; extra == "tests"
263
+ Requires-Dist: pytest-mock>=3; extra == "tests"
260
264
  Provides-Extra: text
261
265
  Requires-Dist: termcolor; extra == "text"
262
266
  Requires-Dist: wcwidth; extra == "text"
@@ -1,6 +1,6 @@
1
1
  anemoi/utils/__init__.py,sha256=uVhpF-VjIl_4mMywOVtgTutgsdIsqz-xdkwxeMhzuag,730
2
2
  anemoi/utils/__main__.py,sha256=6LlE4MYrPvqqrykxXh7XMi50UZteUY59NeM8P9Zs2dU,910
3
- anemoi/utils/_version.py,sha256=ggjVlIoxYrdpYCbFUc7ixm914S2kJVBvieeqeCBnvjY,513
3
+ anemoi/utils/_version.py,sha256=B7w9p3e_jRrhCAcRVz7MulIQ9D0PoLvF57YvgUIilfM,513
4
4
  anemoi/utils/caching.py,sha256=rXbeAmpBcMbbfN4EVblaHWKicsrtx1otER84FEBtz98,6183
5
5
  anemoi/utils/checkpoints.py,sha256=PydBqA-wI8_05zU-3yT-ZP53GnvuqB7vPXl-w6-9XX8,9541
6
6
  anemoi/utils/cli.py,sha256=Cd3ESQkNWecbGnJjkR_SKHdFPETJWFrHqg5ovtANDKs,6522
@@ -30,14 +30,18 @@ anemoi/utils/commands/transfer.py,sha256=29o1RQ46dCJ6kTD1sbxp8XFgB-Qx3RceMccypBM
30
30
  anemoi/utils/mars/__init__.py,sha256=b-Lc3L1TAQd9ODs0Z1YSJzgZCO1K_M3DSgx_yd2qXvM,2724
31
31
  anemoi/utils/mars/mars.yaml,sha256=R0dujp75lLA4wCWhPeOQnzJ45WZAYLT8gpx509cBFlc,66
32
32
  anemoi/utils/mars/requests.py,sha256=VFMHBVAAl0_2lOcMBa1lvaKHctN0lDJsI6_U4BucGew,1142
33
+ anemoi/utils/mlflow/__init__.py,sha256=hCW0QcLHJmE-C1r38P27_ZOvCLNewex5iQEtZqx2ckI,393
34
+ anemoi/utils/mlflow/auth.py,sha256=hMuS3vrX4VDfEFiIlRM_7U6XHvYRGU149doi4jlgsW8,8092
35
+ anemoi/utils/mlflow/client.py,sha256=Y34ceLcp1-H0XTt8h8-IhHKX9bApc_QJcgVrzZKtabY,2752
36
+ anemoi/utils/mlflow/utils.py,sha256=2zwNqkS69TXKz-vUy8cdGckJA7jjtjbUZ6P7cj9U8OM,4940
33
37
  anemoi/utils/remote/__init__.py,sha256=7nHu-LRspYW2Fx9GNLjsxpytAUIvhIbOjb0Xmxb-33s,20988
34
- anemoi/utils/remote/s3.py,sha256=VgxNuJpJBnfxwkkV9TyrSkf7fjIuHqy1YG9vRdj9QRc,19978
38
+ anemoi/utils/remote/s3.py,sha256=DxO_TjmetX_r3ZvGaHjpz40oqvcQYP3Vd_A4ojMGlSA,21379
35
39
  anemoi/utils/remote/ssh.py,sha256=xNtsawh8okytCKRehkRCVExbHZj-CRUQNormEHglfuw,8088
36
40
  anemoi/utils/schemas/__init__.py,sha256=nkinKlsPLPXEjfTYQT1mpKC4cvs-14w_zBkDRxakwxw,698
37
41
  anemoi/utils/schemas/errors.py,sha256=lgOXzVTYzAE0qWQf3OZ42vCWixv8lilSqLLhzARBmvI,1831
38
- anemoi_utils-0.4.26.dist-info/licenses/LICENSE,sha256=8HznKF1Vi2IvfLsKNE5A2iVyiri3pRjRPvPC9kxs6qk,11354
39
- anemoi_utils-0.4.26.dist-info/METADATA,sha256=tx5q6lKfqmvh5eq9ty0Pw1e8LtzX4wysYzm_lVNeIQQ,15486
40
- anemoi_utils-0.4.26.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
41
- anemoi_utils-0.4.26.dist-info/entry_points.txt,sha256=LENOkn88xzFQo-V59AKoA_F_cfYQTJYtrNTtf37YgHY,60
42
- anemoi_utils-0.4.26.dist-info/top_level.txt,sha256=DYn8VPs-fNwr7fNH9XIBqeXIwiYYd2E2k5-dUFFqUz0,7
43
- anemoi_utils-0.4.26.dist-info/RECORD,,
42
+ anemoi_utils-0.4.28.dist-info/licenses/LICENSE,sha256=8HznKF1Vi2IvfLsKNE5A2iVyiri3pRjRPvPC9kxs6qk,11354
43
+ anemoi_utils-0.4.28.dist-info/METADATA,sha256=CxRjsdH4Audnqnun7v-FSgCNxxSVFtwIuSQqgnoIHUk,15656
44
+ anemoi_utils-0.4.28.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
45
+ anemoi_utils-0.4.28.dist-info/entry_points.txt,sha256=LENOkn88xzFQo-V59AKoA_F_cfYQTJYtrNTtf37YgHY,60
46
+ anemoi_utils-0.4.28.dist-info/top_level.txt,sha256=DYn8VPs-fNwr7fNH9XIBqeXIwiYYd2E2k5-dUFFqUz0,7
47
+ anemoi_utils-0.4.28.dist-info/RECORD,,