anemoi-utils 0.4.12__py3-none-any.whl → 0.4.14__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/__init__.py +1 -0
- anemoi/utils/__main__.py +12 -2
- anemoi/utils/_version.py +9 -4
- anemoi/utils/caching.py +138 -13
- anemoi/utils/checkpoints.py +81 -13
- anemoi/utils/cli.py +83 -7
- anemoi/utils/commands/__init__.py +4 -0
- anemoi/utils/commands/config.py +19 -2
- anemoi/utils/commands/requests.py +18 -2
- anemoi/utils/compatibility.py +6 -5
- anemoi/utils/config.py +254 -23
- anemoi/utils/dates.py +204 -50
- anemoi/utils/devtools.py +68 -7
- anemoi/utils/grib.py +30 -9
- anemoi/utils/grids.py +85 -8
- anemoi/utils/hindcasts.py +25 -8
- anemoi/utils/humanize.py +357 -52
- anemoi/utils/logs.py +31 -3
- anemoi/utils/mars/__init__.py +46 -12
- anemoi/utils/mars/requests.py +15 -1
- anemoi/utils/provenance.py +189 -32
- anemoi/utils/registry.py +234 -44
- anemoi/utils/remote/__init__.py +386 -38
- anemoi/utils/remote/s3.py +252 -29
- anemoi/utils/remote/ssh.py +140 -8
- anemoi/utils/s3.py +77 -4
- anemoi/utils/sanitise.py +52 -7
- anemoi/utils/testing.py +182 -0
- anemoi/utils/text.py +218 -54
- anemoi/utils/timer.py +91 -15
- {anemoi_utils-0.4.12.dist-info → anemoi_utils-0.4.14.dist-info}/METADATA +8 -4
- anemoi_utils-0.4.14.dist-info/RECORD +38 -0
- {anemoi_utils-0.4.12.dist-info → anemoi_utils-0.4.14.dist-info}/WHEEL +1 -1
- anemoi_utils-0.4.12.dist-info/RECORD +0 -37
- {anemoi_utils-0.4.12.dist-info → anemoi_utils-0.4.14.dist-info}/entry_points.txt +0 -0
- {anemoi_utils-0.4.12.dist-info → anemoi_utils-0.4.14.dist-info/licenses}/LICENSE +0 -0
- {anemoi_utils-0.4.12.dist-info → anemoi_utils-0.4.14.dist-info}/top_level.txt +0 -0
anemoi/utils/s3.py
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
# nor does it submit to any jurisdiction.
|
|
9
9
|
|
|
10
10
|
import warnings
|
|
11
|
+
from typing import Any
|
|
12
|
+
from typing import Callable
|
|
13
|
+
from typing import Optional
|
|
11
14
|
|
|
12
15
|
from .remote import transfer
|
|
13
16
|
from .remote.s3 import delete as delete_
|
|
@@ -21,7 +24,21 @@ warnings.warn(
|
|
|
21
24
|
)
|
|
22
25
|
|
|
23
26
|
|
|
24
|
-
def s3_client(*args, **kwargs):
|
|
27
|
+
def s3_client(*args: Any, **kwargs: Any) -> Any:
|
|
28
|
+
"""Create an S3 client.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
*args : Any
|
|
33
|
+
Positional arguments for the S3 client.
|
|
34
|
+
**kwargs : Any
|
|
35
|
+
Keyword arguments for the S3 client.
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
Any
|
|
40
|
+
The S3 client.
|
|
41
|
+
"""
|
|
25
42
|
warnings.warn(
|
|
26
43
|
"The 's3_client' function (from anemoi.utils.s3 import s3_client) function is deprecated and will be removed in a future release. "
|
|
27
44
|
"Please use the 's3_client' function (from anemoi.utils.remote.s3 import s3_client) instead.",
|
|
@@ -31,7 +48,35 @@ def s3_client(*args, **kwargs):
|
|
|
31
48
|
return s3_client_(*args, **kwargs)
|
|
32
49
|
|
|
33
50
|
|
|
34
|
-
def upload(
|
|
51
|
+
def upload(
|
|
52
|
+
source: str,
|
|
53
|
+
target: str,
|
|
54
|
+
*,
|
|
55
|
+
overwrite: bool = False,
|
|
56
|
+
resume: bool = False,
|
|
57
|
+
verbosity: int = 1,
|
|
58
|
+
progress: Optional[Callable] = None,
|
|
59
|
+
threads: int = 1,
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Upload a file to S3.
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
source : str
|
|
66
|
+
The source file path.
|
|
67
|
+
target : str
|
|
68
|
+
The target S3 path.
|
|
69
|
+
overwrite : bool, optional
|
|
70
|
+
Whether to overwrite the target file, by default False.
|
|
71
|
+
resume : bool, optional
|
|
72
|
+
Whether to resume a previous upload, by default False.
|
|
73
|
+
verbosity : int, optional
|
|
74
|
+
The verbosity level, by default 1.
|
|
75
|
+
progress : Callable, optional
|
|
76
|
+
A callback function for progress updates, by default None.
|
|
77
|
+
threads : int, optional
|
|
78
|
+
The number of threads to use, by default 1.
|
|
79
|
+
"""
|
|
35
80
|
warnings.warn(
|
|
36
81
|
"The 'upload' function (from anemoi.utils.s3 import upload) function is deprecated and will be removed in a future release. "
|
|
37
82
|
"Please use the 'transfer' function (from anemoi.utils.remote import transfer) instead.",
|
|
@@ -43,7 +88,21 @@ def upload(source, target, *, overwrite=False, resume=False, verbosity=1, progre
|
|
|
43
88
|
)
|
|
44
89
|
|
|
45
90
|
|
|
46
|
-
def download(*args, **kwargs):
|
|
91
|
+
def download(*args: Any, **kwargs: Any) -> Any:
|
|
92
|
+
"""Download a file from S3.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
*args : Any
|
|
97
|
+
Positional arguments for the download.
|
|
98
|
+
**kwargs : Any
|
|
99
|
+
Keyword arguments for the download.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
Any
|
|
104
|
+
The result of the download.
|
|
105
|
+
"""
|
|
47
106
|
warnings.warn(
|
|
48
107
|
"The 'download' function (from anemoi.utils.s3 import download) function is deprecated and will be removed in a future release. "
|
|
49
108
|
"Please use the 'transfer' function (from anemoi.utils.remote import transfer) instead.",
|
|
@@ -53,7 +112,21 @@ def download(*args, **kwargs):
|
|
|
53
112
|
return transfer(*args, **kwargs)
|
|
54
113
|
|
|
55
114
|
|
|
56
|
-
def delete(*args, **kwargs):
|
|
115
|
+
def delete(*args: Any, **kwargs: Any) -> Any:
|
|
116
|
+
"""Delete a file from S3.
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
*args : Any
|
|
121
|
+
Positional arguments for the delete.
|
|
122
|
+
**kwargs : Any
|
|
123
|
+
Keyword arguments for the delete.
|
|
124
|
+
|
|
125
|
+
Returns
|
|
126
|
+
-------
|
|
127
|
+
Any
|
|
128
|
+
The result of the delete.
|
|
129
|
+
"""
|
|
57
130
|
warnings.warn(
|
|
58
131
|
"The 'delete' function (from anemoi.utils.s3 import delete) function is deprecated and will be removed in a future release. "
|
|
59
132
|
"Please use the 'transfer' function (from anemoi.utils.remote.s3 import delete) instead.",
|
anemoi/utils/sanitise.py
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import os
|
|
12
12
|
import re
|
|
13
13
|
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
14
15
|
from urllib.parse import parse_qs
|
|
15
16
|
from urllib.parse import urlencode
|
|
16
17
|
from urllib.parse import urlparse
|
|
@@ -22,10 +23,18 @@ RE1 = re.compile(r"{([^}]*)}")
|
|
|
22
23
|
RE2 = re.compile(r"\(([^}]*)\)")
|
|
23
24
|
|
|
24
25
|
|
|
25
|
-
def sanitise(obj):
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
def sanitise(obj: Any) -> Any:
|
|
27
|
+
"""Sanitise an object by replacing all full paths with shortened versions and URL passwords with '***'.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
obj : Any
|
|
32
|
+
The object to sanitise.
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
Any
|
|
37
|
+
The sanitised object.
|
|
29
38
|
"""
|
|
30
39
|
|
|
31
40
|
if isinstance(obj, dict):
|
|
@@ -43,7 +52,19 @@ def sanitise(obj):
|
|
|
43
52
|
return obj
|
|
44
53
|
|
|
45
54
|
|
|
46
|
-
def _sanitise_string(obj):
|
|
55
|
+
def _sanitise_string(obj: str) -> str:
|
|
56
|
+
"""Sanitise a string by replacing full paths and URL passwords.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
obj : str
|
|
61
|
+
The string to sanitise.
|
|
62
|
+
|
|
63
|
+
Returns
|
|
64
|
+
-------
|
|
65
|
+
str
|
|
66
|
+
The sanitised string.
|
|
67
|
+
"""
|
|
47
68
|
|
|
48
69
|
parsed = urlparse(obj, allow_fragments=True)
|
|
49
70
|
|
|
@@ -56,7 +77,19 @@ def _sanitise_string(obj):
|
|
|
56
77
|
return obj
|
|
57
78
|
|
|
58
79
|
|
|
59
|
-
def _sanitise_url(parsed):
|
|
80
|
+
def _sanitise_url(parsed: Any) -> str:
|
|
81
|
+
"""Sanitise a URL by replacing passwords with '***'.
|
|
82
|
+
|
|
83
|
+
Parameters
|
|
84
|
+
----------
|
|
85
|
+
parsed : Any
|
|
86
|
+
The parsed URL.
|
|
87
|
+
|
|
88
|
+
Returns
|
|
89
|
+
-------
|
|
90
|
+
str
|
|
91
|
+
The sanitised URL.
|
|
92
|
+
"""
|
|
60
93
|
|
|
61
94
|
LIST = [
|
|
62
95
|
"pass",
|
|
@@ -100,7 +133,19 @@ def _sanitise_url(parsed):
|
|
|
100
133
|
return urlunparse([scheme, netloc, path, params, query, fragment])
|
|
101
134
|
|
|
102
135
|
|
|
103
|
-
def _sanitise_path(path):
|
|
136
|
+
def _sanitise_path(path: str) -> str:
|
|
137
|
+
"""Sanitise a file path by shortening it.
|
|
138
|
+
|
|
139
|
+
Parameters
|
|
140
|
+
----------
|
|
141
|
+
path : str
|
|
142
|
+
The file path to sanitise.
|
|
143
|
+
|
|
144
|
+
Returns
|
|
145
|
+
-------
|
|
146
|
+
str
|
|
147
|
+
The sanitised file path.
|
|
148
|
+
"""
|
|
104
149
|
bits = list(reversed(Path(path).parts))
|
|
105
150
|
result = [bits.pop(0)]
|
|
106
151
|
for bit in bits:
|
anemoi/utils/testing.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# (C) Copyright 2025- 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
|
+
import atexit
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import tempfile
|
|
15
|
+
import threading
|
|
16
|
+
|
|
17
|
+
from multiurl import download
|
|
18
|
+
|
|
19
|
+
LOG = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
TEST_DATA_URL = "https://object-store.os-api.cci1.ecmwf.int/ml-tests/test-data/samples/"
|
|
22
|
+
|
|
23
|
+
lock = threading.RLock()
|
|
24
|
+
TEMPORARY_DIRECTORY = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _temporary_directory() -> str:
|
|
28
|
+
"""Return a temporary directory in which to download test data.
|
|
29
|
+
|
|
30
|
+
Returns
|
|
31
|
+
-------
|
|
32
|
+
str
|
|
33
|
+
The path to the temporary directory.
|
|
34
|
+
"""
|
|
35
|
+
global TEMPORARY_DIRECTORY
|
|
36
|
+
with lock:
|
|
37
|
+
if TEMPORARY_DIRECTORY is not None:
|
|
38
|
+
return TEMPORARY_DIRECTORY
|
|
39
|
+
|
|
40
|
+
TEMPORARY_DIRECTORY = tempfile.mkdtemp()
|
|
41
|
+
|
|
42
|
+
# Register a cleanup function to remove the directory at exit
|
|
43
|
+
atexit.register(shutil.rmtree, TEMPORARY_DIRECTORY)
|
|
44
|
+
|
|
45
|
+
return TEMPORARY_DIRECTORY
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _check_path(path: str) -> None:
|
|
49
|
+
"""Check if the given path is normalized, not absolute, and does not start with a dot.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
path : str
|
|
54
|
+
The path to check.
|
|
55
|
+
|
|
56
|
+
Raises
|
|
57
|
+
------
|
|
58
|
+
AssertionError
|
|
59
|
+
If the path is not normalized, is absolute, or starts with a dot.
|
|
60
|
+
"""
|
|
61
|
+
assert os.path.normpath(path) == path, f"Path '{path}' should be normalized"
|
|
62
|
+
assert not os.path.isabs(path), f"Path '{path}' should not be absolute"
|
|
63
|
+
assert not path.startswith("."), f"Path '{path}' should not start with '.'"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def url_for_test_data(path: str) -> str:
|
|
67
|
+
"""Generate the URL for the test data based on the given path.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
path : str
|
|
72
|
+
The relative path to the test data.
|
|
73
|
+
|
|
74
|
+
Returns
|
|
75
|
+
-------
|
|
76
|
+
str
|
|
77
|
+
The full URL to the test data.
|
|
78
|
+
"""
|
|
79
|
+
_check_path(path)
|
|
80
|
+
|
|
81
|
+
return f"{TEST_DATA_URL}{path}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_test_data(path: str, gzipped=False) -> str:
|
|
85
|
+
"""Download the test data to a temporary directory and return the local path.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
path : str
|
|
90
|
+
The relative path to the test data.
|
|
91
|
+
gzipped : bool, optional
|
|
92
|
+
Flag indicating if the remote file is gzipped, by default False. The local file will be gunzipped.
|
|
93
|
+
|
|
94
|
+
Returns
|
|
95
|
+
-------
|
|
96
|
+
str
|
|
97
|
+
The local path to the downloaded test data.
|
|
98
|
+
"""
|
|
99
|
+
_check_path(path)
|
|
100
|
+
|
|
101
|
+
target = os.path.normpath(os.path.join(_temporary_directory(), path))
|
|
102
|
+
with lock:
|
|
103
|
+
if os.path.exists(target):
|
|
104
|
+
return target
|
|
105
|
+
|
|
106
|
+
os.makedirs(os.path.dirname(target), exist_ok=True)
|
|
107
|
+
url = url_for_test_data(path)
|
|
108
|
+
|
|
109
|
+
if gzipped:
|
|
110
|
+
url += ".gz"
|
|
111
|
+
target += ".gz"
|
|
112
|
+
|
|
113
|
+
LOG.info(f"Downloading test data from {url} to {target}")
|
|
114
|
+
|
|
115
|
+
download(url, target)
|
|
116
|
+
|
|
117
|
+
if gzipped:
|
|
118
|
+
import gzip
|
|
119
|
+
|
|
120
|
+
with gzip.open(target, "rb") as f_in:
|
|
121
|
+
with open(target[:-3], "wb") as f_out:
|
|
122
|
+
shutil.copyfileobj(f_in, f_out)
|
|
123
|
+
os.remove(target)
|
|
124
|
+
target = target[:-3]
|
|
125
|
+
|
|
126
|
+
return target
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_test_archive(path: str, extension=".extracted") -> str:
|
|
130
|
+
"""Download an archive file (.zip, .tar, .tar.gz, .tar.bz2, .tar.xz) to a temporary directory
|
|
131
|
+
unpack it, and return the local path to the directory containing the extracted files.
|
|
132
|
+
|
|
133
|
+
Parameters
|
|
134
|
+
----------
|
|
135
|
+
path : str
|
|
136
|
+
The relative path to the test data.
|
|
137
|
+
extension : str, optional
|
|
138
|
+
The extension to add to the extracted directory, by default '.extracted'
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
str
|
|
143
|
+
The local path to the downloaded test data.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
with lock:
|
|
147
|
+
|
|
148
|
+
archive = get_test_data(path)
|
|
149
|
+
target = archive + extension
|
|
150
|
+
|
|
151
|
+
shutil.unpack_archive(archive, os.path.dirname(target) + ".tmp")
|
|
152
|
+
os.rename(os.path.dirname(target) + ".tmp", target)
|
|
153
|
+
|
|
154
|
+
return target
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def packages_installed(*names) -> bool:
|
|
158
|
+
"""Check if all the given packages are installed.
|
|
159
|
+
|
|
160
|
+
Use this function to check if the required packages are installed before running tests.
|
|
161
|
+
|
|
162
|
+
>>> @pytest.mark.skipif(not packages_installed("foo", "bar"), reason="Packages 'foo' and 'bar' are not installed")
|
|
163
|
+
>>> def test_foo_bar() -> None:
|
|
164
|
+
>>> ...
|
|
165
|
+
|
|
166
|
+
Parameters
|
|
167
|
+
----------
|
|
168
|
+
names : str
|
|
169
|
+
The names of the packages to check.
|
|
170
|
+
|
|
171
|
+
Returns
|
|
172
|
+
-------
|
|
173
|
+
bool:
|
|
174
|
+
Flag indicating if all the packages are installed."
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
for name in names:
|
|
178
|
+
try:
|
|
179
|
+
__import__(name)
|
|
180
|
+
except ImportError:
|
|
181
|
+
return False
|
|
182
|
+
return True
|