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.

Files changed (37) hide show
  1. anemoi/utils/__init__.py +1 -0
  2. anemoi/utils/__main__.py +12 -2
  3. anemoi/utils/_version.py +9 -4
  4. anemoi/utils/caching.py +138 -13
  5. anemoi/utils/checkpoints.py +81 -13
  6. anemoi/utils/cli.py +83 -7
  7. anemoi/utils/commands/__init__.py +4 -0
  8. anemoi/utils/commands/config.py +19 -2
  9. anemoi/utils/commands/requests.py +18 -2
  10. anemoi/utils/compatibility.py +6 -5
  11. anemoi/utils/config.py +254 -23
  12. anemoi/utils/dates.py +204 -50
  13. anemoi/utils/devtools.py +68 -7
  14. anemoi/utils/grib.py +30 -9
  15. anemoi/utils/grids.py +85 -8
  16. anemoi/utils/hindcasts.py +25 -8
  17. anemoi/utils/humanize.py +357 -52
  18. anemoi/utils/logs.py +31 -3
  19. anemoi/utils/mars/__init__.py +46 -12
  20. anemoi/utils/mars/requests.py +15 -1
  21. anemoi/utils/provenance.py +189 -32
  22. anemoi/utils/registry.py +234 -44
  23. anemoi/utils/remote/__init__.py +386 -38
  24. anemoi/utils/remote/s3.py +252 -29
  25. anemoi/utils/remote/ssh.py +140 -8
  26. anemoi/utils/s3.py +77 -4
  27. anemoi/utils/sanitise.py +52 -7
  28. anemoi/utils/testing.py +182 -0
  29. anemoi/utils/text.py +218 -54
  30. anemoi/utils/timer.py +91 -15
  31. {anemoi_utils-0.4.12.dist-info → anemoi_utils-0.4.14.dist-info}/METADATA +8 -4
  32. anemoi_utils-0.4.14.dist-info/RECORD +38 -0
  33. {anemoi_utils-0.4.12.dist-info → anemoi_utils-0.4.14.dist-info}/WHEEL +1 -1
  34. anemoi_utils-0.4.12.dist-info/RECORD +0 -37
  35. {anemoi_utils-0.4.12.dist-info → anemoi_utils-0.4.14.dist-info}/entry_points.txt +0 -0
  36. {anemoi_utils-0.4.12.dist-info → anemoi_utils-0.4.14.dist-info/licenses}/LICENSE +0 -0
  37. {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(source, target, *, overwrite=False, resume=False, verbosity=1, progress=None, threads=1) -> None:
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
- """sanitise an object:
27
- - by replacing all full paths with shortened versions.
28
- - by replacing URL passwords with '***'.
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:
@@ -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