anemoi-utils 0.4.36__tar.gz → 0.4.38__tar.gz

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 (113) hide show
  1. anemoi_utils-0.4.38/.release-please-manifest.json +3 -0
  2. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/CHANGELOG.md +27 -0
  3. {anemoi_utils-0.4.36/src/anemoi_utils.egg-info → anemoi_utils-0.4.38}/PKG-INFO +1 -1
  4. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/_version.py +3 -3
  5. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/checkpoints.py +38 -17
  6. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/commands/metadata.py +6 -4
  7. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/dates.py +8 -26
  8. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/mlflow/auth.py +115 -17
  9. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/remote/s3.py +2 -2
  10. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/testing.py +1 -1
  11. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38/src/anemoi_utils.egg-info}/PKG-INFO +1 -1
  12. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test_frequency.py +3 -2
  13. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test_mlflow_auth.py +113 -4
  14. anemoi_utils-0.4.36/.release-please-manifest.json +0 -3
  15. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.gitattributes +0 -0
  16. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.github/CODEOWNERS +0 -0
  17. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.github/ci-hpc-config.yml +0 -0
  18. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.github/dependabot.yml +0 -0
  19. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.github/labeler.yml +0 -0
  20. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.github/pull_request_template.md +0 -0
  21. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.github/workflows/downstream-ci-hpc.yml +0 -0
  22. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.github/workflows/pr-conventional-commit.yml +0 -0
  23. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.github/workflows/pr-label-ats.yml +0 -0
  24. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.github/workflows/pr-label-conventional-commits.yml +0 -0
  25. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.github/workflows/pr-label-file-based.yml +0 -0
  26. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.github/workflows/pr-label-public.yml +0 -0
  27. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.github/workflows/python-publish.yml +0 -0
  28. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.github/workflows/python-pull-request.yml +0 -0
  29. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.github/workflows/readthedocs-pr-update.yml +0 -0
  30. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.github/workflows/release-please.yml +0 -0
  31. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.gitignore +0 -0
  32. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.pre-commit-config.yaml +0 -0
  33. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.readthedocs.yaml +0 -0
  34. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/.release-please-config.json +0 -0
  35. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/CONTRIBUTORS.md +0 -0
  36. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/LICENSE +0 -0
  37. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/README.md +0 -0
  38. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/Makefile +0 -0
  39. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/_static/logo.png +0 -0
  40. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/_static/style.css +0 -0
  41. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/_templates/.gitkeep +0 -0
  42. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/_templates/apidoc/package.rst.jinja +0 -0
  43. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/conf.py +0 -0
  44. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/index.rst +0 -0
  45. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/installing.rst +0 -0
  46. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/modules/checkpoints.rst +0 -0
  47. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/modules/config.rst +0 -0
  48. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/modules/dates.rst +0 -0
  49. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/modules/grib.rst +0 -0
  50. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/modules/humanize.rst +0 -0
  51. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/modules/provenance.rst +0 -0
  52. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/modules/s3.rst +0 -0
  53. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/modules/testing.rst +0 -0
  54. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/modules/text.rst +0 -0
  55. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/docs/scripts/api_build.sh +0 -0
  56. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/pyproject.toml +0 -0
  57. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/setup.cfg +0 -0
  58. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/__init__.py +0 -0
  59. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/__main__.py +0 -0
  60. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/_environment.py +0 -0
  61. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/caching.py +0 -0
  62. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/cli.py +0 -0
  63. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/commands/__init__.py +0 -0
  64. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/commands/config.py +0 -0
  65. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/commands/requests.py +0 -0
  66. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/commands/transfer.py +0 -0
  67. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/compatibility.py +0 -0
  68. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/config.py +0 -0
  69. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/devtools.py +0 -0
  70. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/grib.py +0 -0
  71. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/grids.py +0 -0
  72. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/hindcasts.py +0 -0
  73. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/humanize.py +0 -0
  74. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/logs.py +0 -0
  75. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/mars/__init__.py +0 -0
  76. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/mars/mars.yaml +0 -0
  77. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/mars/requests.py +0 -0
  78. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/mlflow/__init__.py +0 -0
  79. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/mlflow/client.py +0 -0
  80. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/mlflow/utils.py +0 -0
  81. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/provenance.py +0 -0
  82. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/registry.py +0 -0
  83. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/remote/__init__.py +0 -0
  84. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/remote/ssh.py +0 -0
  85. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/rules.py +0 -0
  86. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/s3.py +0 -0
  87. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/sanitise.py +0 -0
  88. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/sanitize.py +0 -0
  89. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/schemas/__init__.py +0 -0
  90. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/schemas/errors.py +0 -0
  91. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/text.py +0 -0
  92. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi/utils/timer.py +0 -0
  93. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi_utils.egg-info/SOURCES.txt +0 -0
  94. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi_utils.egg-info/dependency_links.txt +0 -0
  95. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi_utils.egg-info/entry_points.txt +0 -0
  96. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi_utils.egg-info/requires.txt +0 -0
  97. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/src/anemoi_utils.egg-info/top_level.txt +0 -0
  98. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test-transfer-data/directory/b/c/x +0 -0
  99. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test-transfer-data/directory/b/y +0 -0
  100. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test-transfer-data/directory/exotic filename ;^/"'[=.,#]()/303/252/303/274/303/247/303/262/342/234/205.txt" +0 -0
  101. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test-transfer-data/directory/z +0 -0
  102. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test-transfer-data/file +0 -0
  103. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test_caching.py +0 -0
  104. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test_checkpoints.py +0 -0
  105. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test_compatibility.py +0 -0
  106. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test_dates.py +0 -0
  107. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test_mlflow_client.py +0 -0
  108. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test_provenance.py +0 -0
  109. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test_registry.py +0 -0
  110. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test_remote.py +0 -0
  111. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test_s3.py +0 -0
  112. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test_sanitise.py +0 -0
  113. {anemoi_utils-0.4.36 → anemoi_utils-0.4.38}/tests/test_utils.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.4.38"
3
+ }
@@ -8,6 +8,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
  Please add your functional changes to the appropriate section in the PR.
9
9
  Keep it human-readable, your future self will thank you!
10
10
 
11
+ ## [0.4.38](https://github.com/ecmwf/anemoi-utils/compare/0.4.37...0.4.38) (2025-10-22)
12
+
13
+
14
+ ### Features
15
+
16
+ * **testing:** Add download timeout ([#230](https://github.com/ecmwf/anemoi-utils/issues/230)) ([721d114](https://github.com/ecmwf/anemoi-utils/commit/721d114f2702985d9fbabf68f384f7ccedb7cfb3))
17
+ * **testing:** Sane test data download retries ([#227](https://github.com/ecmwf/anemoi-utils/issues/227)) ([1e08996](https://github.com/ecmwf/anemoi-utils/commit/1e089962b14f05f4aa56eb66a884bfe50ce60dcc))
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * Fix frequency_to_string outputing 108000s ([#216](https://github.com/ecmwf/anemoi-utils/issues/216)) ([5806a0c](https://github.com/ecmwf/anemoi-utils/commit/5806a0c996235fb3a19d34eeb25813280c5c989f))
23
+ * Support dicts of supporting_arrays ([#229](https://github.com/ecmwf/anemoi-utils/issues/229)) ([9badbad](https://github.com/ecmwf/anemoi-utils/commit/9badbad360609d254717e0d32c6171beb903eb21))
24
+
25
+ ## [0.4.37](https://github.com/ecmwf/anemoi-utils/compare/0.4.36...0.4.37) (2025-09-30)
26
+
27
+
28
+ ### Features
29
+
30
+ * **mlflow auth:** Support for multiple servers ([#217](https://github.com/ecmwf/anemoi-utils/issues/217)) ([8ccfb1a](https://github.com/ecmwf/anemoi-utils/commit/8ccfb1ab063cccfec5852c386580036286b097c6))
31
+
32
+
33
+ ### Bug Fixes
34
+
35
+ * Update s3 chunk size to 10 MB ([#220](https://github.com/ecmwf/anemoi-utils/issues/220)) ([aa20fa8](https://github.com/ecmwf/anemoi-utils/commit/aa20fa8b0b572fb6fa510b2f28c2b8b8a2f76d7c))
36
+ * Use `yaml` and `json` flag in metadata get command ([#222](https://github.com/ecmwf/anemoi-utils/issues/222)) ([6af46c4](https://github.com/ecmwf/anemoi-utils/commit/6af46c4e715fc55aca374d2112976aa7d1bac589))
37
+
11
38
  ## [0.4.36](https://github.com/ecmwf/anemoi-utils/compare/0.4.35...0.4.36) (2025-09-22)
12
39
 
13
40
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anemoi-utils
3
- Version: 0.4.36
3
+ Version: 0.4.38
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
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.4.36'
32
- __version_tuple__ = version_tuple = (0, 4, 36)
31
+ __version__ = version = '0.4.38'
32
+ __version_tuple__ = version_tuple = (0, 4, 38)
33
33
 
34
- __commit_id__ = commit_id = 'g67b40f99c'
34
+ __commit_id__ = commit_id = 'g30b2d7452'
@@ -20,6 +20,7 @@ import zipfile
20
20
  from collections.abc import Callable
21
21
  from tempfile import TemporaryDirectory
22
22
 
23
+ import numpy as np
23
24
  import tqdm
24
25
 
25
26
  LOG = logging.getLogger(__name__)
@@ -147,6 +148,41 @@ def load_supporting_arrays(zipf: zipfile.ZipFile, entries: dict) -> dict:
147
148
  return supporting_arrays
148
149
 
149
150
 
151
+ def _get_supporting_arrays_paths(directory: str, folder: str, supporting_arrays: dict | np.ndarray) -> dict:
152
+ """Get the paths of supporting arrays."""
153
+ if supporting_arrays is None:
154
+ return {}
155
+
156
+ if isinstance(supporting_arrays, dict):
157
+ return {
158
+ new_key: _get_supporting_arrays_paths(f"{directory}/{folder}", new_key, new_value)
159
+ for new_key, new_value in supporting_arrays.items()
160
+ }
161
+
162
+ return dict(
163
+ path=f"{directory}/{folder}.numpy",
164
+ shape=supporting_arrays.shape,
165
+ dtype=str(supporting_arrays.dtype),
166
+ )
167
+
168
+
169
+ def _write_array_to_bytes(array: dict | np.ndarray, name: str, entry: dict, zipf: zipfile.ZipFile) -> None:
170
+ """Write a supporting array to bytes in a zip file."""
171
+ if isinstance(array, dict):
172
+ for sub_name, sub_array in array.items():
173
+ _write_array_to_bytes(sub_array, sub_name, entry[sub_name], zipf)
174
+ return None
175
+
176
+ LOG.info(
177
+ "Saving supporting array `%s` to %s (shape=%s, dtype=%s)",
178
+ name,
179
+ entry["path"],
180
+ entry["shape"],
181
+ entry["dtype"],
182
+ )
183
+ zipf.writestr(entry["path"], array.tobytes())
184
+
185
+
150
186
  def save_metadata(
151
187
  path: str, metadata: dict, *, supporting_arrays: dict = None, name: str = DEFAULT_NAME, folder: str = DEFAULT_FOLDER
152
188
  ) -> None:
@@ -189,29 +225,14 @@ def save_metadata(
189
225
  LOG.info("Saving metadata to %s/%s/%s", directory, folder, name)
190
226
 
191
227
  metadata = metadata.copy()
192
- if supporting_arrays is not None:
193
- metadata["supporting_arrays_paths"] = {
194
- key: dict(path=f"{directory}/{folder}/{key}.numpy", shape=value.shape, dtype=str(value.dtype))
195
- for key, value in supporting_arrays.items()
196
- }
197
- else:
198
- metadata["supporting_arrays_paths"] = {}
228
+ metadata["supporting_arrays_paths"] = _get_supporting_arrays_paths(directory, folder, supporting_arrays)
199
229
 
200
230
  zipf.writestr(
201
231
  f"{directory}/{folder}/{name}",
202
232
  json.dumps(metadata),
203
233
  )
204
234
 
205
- for name, entry in metadata["supporting_arrays_paths"].items():
206
- value = supporting_arrays[name]
207
- LOG.info(
208
- "Saving supporting array `%s` to %s (shape=%s, dtype=%s)",
209
- name,
210
- entry["path"],
211
- entry["shape"],
212
- entry["dtype"],
213
- )
214
- zipf.writestr(entry["path"], value.tobytes())
235
+ _write_array_to_bytes(supporting_arrays, "", metadata["supporting_arrays_paths"], zipf)
215
236
 
216
237
 
217
238
  def _edit_metadata(path: str, name: str, callback: Callable, supporting_arrays: dict | None = None) -> None:
@@ -139,13 +139,13 @@ class Metadata(Command):
139
139
  command_parser.add_argument(
140
140
  "--json",
141
141
  action="store_true",
142
- help="Use the JSON format with ``--dump``, ``--view`` and ``--edit``.",
142
+ help="Use the JSON format with ``--dump``, ``--view``, ``--get`` and ``--edit``.",
143
143
  )
144
144
 
145
145
  command_parser.add_argument(
146
146
  "--yaml",
147
147
  action="store_true",
148
- help="Use the YAML format with ``--dump``, ``--view`` and ``--edit``.",
148
+ help="Use the YAML format with ``--dump``, ``--view``, ``--get`` and ``--edit``.",
149
149
  )
150
150
 
151
151
  def run(self, args: Namespace) -> None:
@@ -315,7 +315,6 @@ class Metadata(Command):
315
315
  args : Namespace
316
316
  The arguments passed to the command.
317
317
  """
318
- from pprint import pprint
319
318
 
320
319
  from anemoi.utils.checkpoints import load_metadata
321
320
 
@@ -335,7 +334,10 @@ class Metadata(Command):
335
334
 
336
335
  print(f"Metadata values for {args.get}: ", end="\n" if isinstance(metadata, (dict, list)) else "")
337
336
  if isinstance(metadata, dict):
338
- pprint(metadata, indent=2, compact=True)
337
+ if args.yaml:
338
+ print(yaml.dump(metadata, indent=2, sort_keys=True))
339
+ return
340
+ print(json.dumps(metadata, indent=2, sort_keys=True))
339
341
  else:
340
342
  print(metadata)
341
343
 
@@ -260,39 +260,21 @@ def frequency_to_string(frequency: datetime.timedelta) -> str:
260
260
  A string representation of the frequency.
261
261
  """
262
262
 
263
- frequency = frequency_to_timedelta(frequency)
263
+ total_seconds = int(frequency.total_seconds())
264
264
 
265
- total_seconds = frequency.total_seconds()
266
265
  if total_seconds < 0:
267
266
  return f"-{frequency_to_string(-frequency)}"
268
- assert int(total_seconds) == total_seconds, total_seconds
269
- total_seconds = int(total_seconds)
270
267
 
271
- seconds = total_seconds
268
+ if total_seconds % (24 * 3600) == 0:
269
+ return f"{total_seconds // (24 * 3600)}d"
272
270
 
273
- days = seconds // (24 * 3600)
274
- seconds %= 24 * 3600
275
- hours = seconds // 3600
276
- seconds %= 3600
277
- minutes = seconds // 60
278
- seconds %= 60
271
+ if total_seconds % 3600 == 0:
272
+ return f"{total_seconds // 3600}h"
279
273
 
280
- if days > 0 and hours == 0 and minutes == 0 and seconds == 0:
281
- return f"{days}d"
274
+ if total_seconds % 60 == 0:
275
+ return f"{total_seconds // 60}m"
282
276
 
283
- if days == 0 and hours > 0 and minutes == 0 and seconds == 0:
284
- return f"{hours}h"
285
-
286
- if days == 0 and hours == 0 and minutes > 0 and seconds == 0:
287
- return f"{minutes}m"
288
-
289
- if days == 0 and hours == 0 and minutes == 0 and seconds > 0:
290
- return f"{seconds}s"
291
-
292
- if days > 0:
293
- return f"{total_seconds}s"
294
-
295
- return str(frequency)
277
+ return f"{total_seconds}s"
296
278
 
297
279
 
298
280
  def frequency_to_seconds(frequency: int | str | datetime.timedelta) -> int:
@@ -13,6 +13,7 @@ from __future__ import annotations
13
13
  import logging
14
14
  import os
15
15
  import time
16
+ import warnings
16
17
  from abc import ABC
17
18
  from abc import abstractmethod
18
19
  from datetime import datetime
@@ -22,10 +23,15 @@ from getpass import getpass
22
23
  from typing import TYPE_CHECKING
23
24
 
24
25
  import requests
26
+ from pydantic import BaseModel
27
+ from pydantic import RootModel
28
+ from pydantic import field_validator
29
+ from pydantic import model_validator
25
30
  from requests.exceptions import HTTPError
26
31
 
32
+ from ..config import CONFIG_LOCK
27
33
  from ..config import config_path
28
- from ..config import load_config
34
+ from ..config import load_raw_config
29
35
  from ..config import save_config
30
36
  from ..remote import robust
31
37
  from ..timer import Timer
@@ -37,6 +43,56 @@ if TYPE_CHECKING:
37
43
  from collections.abc import Callable
38
44
 
39
45
 
46
+ class ServerConfig(BaseModel):
47
+ refresh_token: str | None = None
48
+ refresh_expires: int = 0
49
+
50
+ @field_validator("refresh_expires", mode="before")
51
+ def to_int(cls, value: float | int) -> int:
52
+ if not isinstance(value, int):
53
+ return int(value)
54
+ return value
55
+
56
+
57
+ class ServerStore(RootModel):
58
+ root: dict[str, ServerConfig] = {}
59
+
60
+ def get(self, url: str) -> ServerConfig | None:
61
+ return self.root.get(url)
62
+
63
+ def __getitem__(self, url: str) -> ServerConfig:
64
+ return self.root[url]
65
+
66
+ def items(self):
67
+ return self.root.items()
68
+
69
+ def update(self, url, config: ServerConfig) -> None:
70
+ """Update the server configuration for a given URL."""
71
+ self.root[url] = config
72
+
73
+ @property
74
+ def servers(self) -> list[tuple[str, int]]:
75
+ """List of servers in the store, as a tuple (url, refresh_expires). Ordered most recently used first."""
76
+ return [
77
+ (url, cfg.refresh_expires)
78
+ for url, cfg in sorted(
79
+ self.root.items(),
80
+ key=lambda item: item[1].refresh_expires,
81
+ reverse=True,
82
+ )
83
+ ]
84
+
85
+ @model_validator(mode="before")
86
+ @classmethod
87
+ def load_legacy_format(cls, data: dict) -> dict:
88
+ """Convert legacy single-server config format to multi-server."""
89
+ if isinstance(data, dict) and "url" in data:
90
+ _data = data.copy()
91
+ _url = _data.pop("url")
92
+ data = {_url: ServerConfig(**_data)}
93
+ return data
94
+
95
+
40
96
  class AuthBase(ABC):
41
97
  """Base class for authentication implementations."""
42
98
 
@@ -76,7 +132,7 @@ class NoAuth(AuthBase):
76
132
  class TokenAuth(AuthBase):
77
133
  """Manage authentication with a keycloak token server."""
78
134
 
79
- config_file = "mlflow-token.json"
135
+ _config_file = "mlflow-token.json"
80
136
 
81
137
  def __init__(
82
138
  self,
@@ -101,10 +157,16 @@ class TokenAuth(AuthBase):
101
157
  self.target_env_var = target_env_var
102
158
  self._enabled = enabled
103
159
 
104
- config = self.load_config()
160
+ store = self._get_store()
161
+ config = store.get(self.url)
162
+
163
+ if config is not None:
164
+ self._refresh_token = config.refresh_token
165
+ self.refresh_expires = config.refresh_expires
166
+ else:
167
+ self._refresh_token = None
168
+ self.refresh_expires = 0
105
169
 
106
- self._refresh_token = config.get("refresh_token")
107
- self.refresh_expires = config.get("refresh_expires", 0)
108
170
  self.access_token = None
109
171
  self.access_expires = 0
110
172
 
@@ -124,17 +186,50 @@ class TokenAuth(AuthBase):
124
186
  self._refresh_token = value
125
187
  self.refresh_expires = time.time() + (REFRESH_EXPIRE_DAYS * 86400) # 86400 seconds in a day
126
188
 
189
+ @staticmethod
190
+ def _get_store() -> ServerStore:
191
+ """Read the server store from disk."""
192
+ with CONFIG_LOCK:
193
+ file = TokenAuth._config_file
194
+ path = config_path(file)
195
+
196
+ if not os.path.exists(path):
197
+ save_config(file, {})
198
+
199
+ if os.path.exists(path) and os.stat(path).st_mode & 0o777 != 0o600:
200
+ os.chmod(path, 0o600)
201
+
202
+ return ServerStore(**load_raw_config(file))
203
+
204
+ @staticmethod
205
+ def get_servers() -> list[tuple[str, int]]:
206
+ """List of all saved servers, as a tuple (url, refresh_expires). Ordered most recently used first."""
207
+ return TokenAuth._get_store().servers
208
+
127
209
  @staticmethod
128
210
  def load_config() -> dict:
129
- path = config_path(TokenAuth.config_file)
211
+ """Load the last used server configuration
130
212
 
131
- if not os.path.exists(path):
132
- save_config(TokenAuth.config_file, {})
213
+ Returns
214
+ -------
215
+ config : dict
216
+ Dictionary with the following keys: `url`, `refresh_token`, `refresh_expires`.
217
+ If no configuration is found, an empty dictionary is returned.
218
+ """
219
+ warnings.warn(
220
+ "TokenAuth.load_config() is deprecated and will be removed in a future release.",
221
+ DeprecationWarning,
222
+ stacklevel=2,
223
+ )
224
+
225
+ store = TokenAuth._get_store()
133
226
 
134
- if os.path.exists(path) and os.stat(path).st_mode & 0o777 != 0o600:
135
- os.chmod(path, 0o600)
227
+ last = {}
228
+ for url, cfg in store.items():
229
+ if cfg.refresh_expires > last.get("refresh_expires", 0):
230
+ last = dict(url=url, **cfg.model_dump())
136
231
 
137
- return load_config(TokenAuth.config_file)
232
+ return last
138
233
 
139
234
  def enabled(fn: Callable) -> Callable: # noqa: N805
140
235
  """Decorator to call or ignore a function based on the `enabled` flag."""
@@ -237,12 +332,15 @@ class TokenAuth(AuthBase):
237
332
  self.log.warning("No refresh token to save.")
238
333
  return
239
334
 
240
- config = {
241
- "url": self.url,
242
- "refresh_token": self.refresh_token,
243
- "refresh_expires": self.refresh_expires,
244
- }
245
- save_config(self.config_file, config)
335
+ server_config = ServerConfig(
336
+ refresh_token=self.refresh_token,
337
+ refresh_expires=self.refresh_expires,
338
+ )
339
+
340
+ with CONFIG_LOCK:
341
+ store = self._get_store()
342
+ store.update(self.url, server_config)
343
+ save_config(self._config_file, store.model_dump())
246
344
 
247
345
  expire_date = datetime.fromtimestamp(self.refresh_expires, tz=timezone.utc)
248
346
  self.log.info(
@@ -262,7 +262,7 @@ def upload_file(source: str, target: str, overwrite: bool, resume: bool, verbosi
262
262
  leave=verbosity >= 2,
263
263
  delay=0 if verbosity > 0 else 10,
264
264
  ) as pbar:
265
- chunk_size = 1024 * 1024
265
+ chunk_size = 1024 * 1024 * 10
266
266
  total = size
267
267
  with open(source, "rb") as f:
268
268
  with closing(obstore.open_writer(s3, obj.key, buffer_size=chunk_size)) as g:
@@ -331,7 +331,7 @@ def download_file(source: str, target: str, overwrite: bool, resume: bool, verbo
331
331
  leave=verbosity >= 2,
332
332
  delay=0 if verbosity > 0 else 10,
333
333
  ) as pbar:
334
- chunk_size = 1024 * 1024
334
+ chunk_size = 1024 * 1024 * 10
335
335
  total = size
336
336
  with closing(obstore.open_reader(s3, obj.key, buffer_size=chunk_size)) as f:
337
337
  with open(target, "wb") as g:
@@ -114,7 +114,7 @@ class GetTestData:
114
114
 
115
115
  LOG.info(f"Downloading test data from {url} to {target}")
116
116
 
117
- download(url, target)
117
+ download(url, target, maximum_retries=5, retry_after=60, timeout=60)
118
118
 
119
119
  if gzipped:
120
120
  import gzip
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anemoi-utils
3
- Version: 0.4.36
3
+ Version: 0.4.38
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
@@ -17,10 +17,11 @@ from anemoi.utils.dates import frequency_to_timedelta
17
17
  def test_frequency_to_string() -> None:
18
18
  """Test the frequency_to_string function for converting timedelta to string."""
19
19
  assert frequency_to_string(datetime.timedelta(hours=1)) == "1h"
20
- assert frequency_to_string(datetime.timedelta(hours=1, minutes=30)) == "1:30:00"
20
+ assert frequency_to_string(datetime.timedelta(hours=1, minutes=30)) == "90m"
21
21
  assert frequency_to_string(datetime.timedelta(days=10)) == "10d"
22
+ assert frequency_to_string(datetime.timedelta(hours=30)) == "30h"
22
23
  assert frequency_to_string(datetime.timedelta(minutes=10)) == "10m"
23
- assert frequency_to_string(datetime.timedelta(minutes=90)) == "1:30:00"
24
+ assert frequency_to_string(datetime.timedelta(minutes=90)) == "90m"
24
25
 
25
26
 
26
27
  def test_frequency_to_timedelta() -> None:
@@ -15,6 +15,8 @@ import time
15
15
  import pytest
16
16
 
17
17
  from anemoi.utils.mlflow.auth import NoAuth
18
+ from anemoi.utils.mlflow.auth import ServerConfig
19
+ from anemoi.utils.mlflow.auth import ServerStore
18
20
  from anemoi.utils.mlflow.auth import TokenAuth
19
21
 
20
22
 
@@ -35,17 +37,19 @@ def mocks(
35
37
  response.update(token_request)
36
38
 
37
39
  config = {
38
- "refresh_token": "old_refresh_token",
39
- "refresh_expires": time.time() + 3600,
40
+ "https://test.url": {
41
+ "refresh_token": "old_refresh_token",
42
+ "refresh_expires": time.time() + 3600,
43
+ }
40
44
  }
41
- config.update(load_config)
45
+ config["https://test.url"].update(load_config)
42
46
 
43
47
  mock_token_request = mocker.patch(
44
48
  "anemoi.utils.mlflow.auth.TokenAuth._token_request",
45
49
  return_value=response,
46
50
  )
47
51
  mocker.patch(
48
- "anemoi.utils.mlflow.auth.load_config",
52
+ "anemoi.utils.mlflow.auth.load_raw_config",
49
53
  return_value=config,
50
54
  )
51
55
  mocker.patch(
@@ -177,3 +181,108 @@ def test_noauth_methods_do_nothing():
177
181
  assert auth.save() is None
178
182
  assert auth.login() is None
179
183
  assert auth.authenticate() is None
184
+
185
+
186
+ def test_legacy_format(mocker: pytest.MockerFixture) -> None:
187
+ mocks(mocker)
188
+
189
+ legacy_config = {
190
+ "url": "https://test.url",
191
+ "refresh_token": "some_refresh_token",
192
+ "refresh_expires": 123,
193
+ }
194
+ new_config = {
195
+ legacy_config["url"]: {
196
+ "refresh_token": legacy_config["refresh_token"],
197
+ "refresh_expires": legacy_config["refresh_expires"],
198
+ }
199
+ }
200
+ mocker.patch(
201
+ "anemoi.utils.mlflow.auth.load_raw_config",
202
+ return_value=legacy_config,
203
+ )
204
+
205
+ # test backwards compatibility of deprecated load_config
206
+ # when this function is removed, also remove this assert
207
+ config = TokenAuth.load_config()
208
+ assert config == legacy_config
209
+
210
+ # test that the store can handle both formats and the outputs are identical
211
+ legacy_store = ServerStore(legacy_config)
212
+ new_store = ServerStore(new_config)
213
+ expected_config = new_config["https://test.url"]
214
+
215
+ assert (
216
+ legacy_store["https://test.url"].model_dump() == new_store["https://test.url"].model_dump() == expected_config
217
+ )
218
+ assert legacy_store.model_dump() == new_store.model_dump() == new_config
219
+
220
+
221
+ multi_config = {
222
+ "https://server-1.url": {
223
+ "refresh_token": "refresh-token-1",
224
+ "refresh_expires": 1,
225
+ },
226
+ "https://server-3.url": {
227
+ "refresh_token": "refresh-token-3",
228
+ "refresh_expires": 3,
229
+ },
230
+ "https://server-2.url": {
231
+ "refresh_token": "refresh-token-2",
232
+ "refresh_expires": 2,
233
+ },
234
+ }
235
+
236
+
237
+ @pytest.mark.parametrize(
238
+ "url, unknown",
239
+ [
240
+ ("https://server-1.url", False),
241
+ ("https://server-2.url", False),
242
+ ("https://server-3.url", False),
243
+ ("https://unknown.url", True),
244
+ ],
245
+ )
246
+ def test_multi_server_format(mocker: pytest.MockerFixture, url: str, unknown: bool) -> None:
247
+ mocks(mocker)
248
+
249
+ mocker.patch(
250
+ "anemoi.utils.mlflow.auth.load_raw_config",
251
+ return_value=multi_config,
252
+ )
253
+
254
+ auth = TokenAuth(url)
255
+
256
+ if unknown:
257
+ assert auth.refresh_token is None
258
+ assert auth.refresh_expires == 0
259
+ else:
260
+ assert auth.refresh_token == multi_config[url]["refresh_token"]
261
+ assert auth.refresh_expires == multi_config[url]["refresh_expires"]
262
+
263
+
264
+ def test_server_store() -> None:
265
+ store = ServerStore(multi_config)
266
+
267
+ config = store["https://server-2.url"]
268
+ assert isinstance(config, ServerConfig)
269
+ assert config.refresh_token == "refresh-token-2"
270
+ assert config.refresh_expires == 2
271
+
272
+ assert store.get("https://unknown.url") is None
273
+
274
+ # ordered by expiry time, highest first
275
+ assert store.servers == [("https://server-3.url", 3), ("https://server-2.url", 2), ("https://server-1.url", 1)]
276
+
277
+ assert ServerStore({}).model_dump() == {}
278
+
279
+
280
+ def test_utils_interface():
281
+ """TokenAuth uses the utils CONFIG_LOCK when reading and writing the server store to ensure thread safety.
282
+ Ensure that CONFIG_LOCK stays a reentrant lock, if it were a normal lock it would deadlock itself.
283
+ """
284
+ from threading import RLock
285
+
286
+ from anemoi.utils.config import CONFIG_LOCK
287
+
288
+ assert isinstance(CONFIG_LOCK, type(RLock()))
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.4.36"
3
- }
File without changes
File without changes
File without changes
File without changes