anemoi-utils 0.4.36__tar.gz → 0.4.37__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.37/.release-please-manifest.json +3 -0
  2. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/CHANGELOG.md +13 -0
  3. {anemoi_utils-0.4.36/src/anemoi_utils.egg-info → anemoi_utils-0.4.37}/PKG-INFO +1 -1
  4. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/_version.py +3 -3
  5. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/commands/metadata.py +6 -4
  6. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/mlflow/auth.py +115 -17
  7. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/remote/s3.py +2 -2
  8. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37/src/anemoi_utils.egg-info}/PKG-INFO +1 -1
  9. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test_mlflow_auth.py +113 -4
  10. anemoi_utils-0.4.36/.release-please-manifest.json +0 -3
  11. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.gitattributes +0 -0
  12. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.github/CODEOWNERS +0 -0
  13. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.github/ci-hpc-config.yml +0 -0
  14. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.github/dependabot.yml +0 -0
  15. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.github/labeler.yml +0 -0
  16. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.github/pull_request_template.md +0 -0
  17. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.github/workflows/downstream-ci-hpc.yml +0 -0
  18. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.github/workflows/pr-conventional-commit.yml +0 -0
  19. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.github/workflows/pr-label-ats.yml +0 -0
  20. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.github/workflows/pr-label-conventional-commits.yml +0 -0
  21. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.github/workflows/pr-label-file-based.yml +0 -0
  22. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.github/workflows/pr-label-public.yml +0 -0
  23. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.github/workflows/python-publish.yml +0 -0
  24. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.github/workflows/python-pull-request.yml +0 -0
  25. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.github/workflows/readthedocs-pr-update.yml +0 -0
  26. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.github/workflows/release-please.yml +0 -0
  27. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.gitignore +0 -0
  28. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.pre-commit-config.yaml +0 -0
  29. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.readthedocs.yaml +0 -0
  30. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/.release-please-config.json +0 -0
  31. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/CONTRIBUTORS.md +0 -0
  32. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/LICENSE +0 -0
  33. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/README.md +0 -0
  34. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/Makefile +0 -0
  35. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/_static/logo.png +0 -0
  36. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/_static/style.css +0 -0
  37. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/_templates/.gitkeep +0 -0
  38. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/_templates/apidoc/package.rst.jinja +0 -0
  39. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/conf.py +0 -0
  40. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/index.rst +0 -0
  41. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/installing.rst +0 -0
  42. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/modules/checkpoints.rst +0 -0
  43. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/modules/config.rst +0 -0
  44. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/modules/dates.rst +0 -0
  45. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/modules/grib.rst +0 -0
  46. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/modules/humanize.rst +0 -0
  47. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/modules/provenance.rst +0 -0
  48. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/modules/s3.rst +0 -0
  49. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/modules/testing.rst +0 -0
  50. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/modules/text.rst +0 -0
  51. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/docs/scripts/api_build.sh +0 -0
  52. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/pyproject.toml +0 -0
  53. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/setup.cfg +0 -0
  54. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/__init__.py +0 -0
  55. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/__main__.py +0 -0
  56. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/_environment.py +0 -0
  57. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/caching.py +0 -0
  58. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/checkpoints.py +0 -0
  59. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/cli.py +0 -0
  60. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/commands/__init__.py +0 -0
  61. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/commands/config.py +0 -0
  62. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/commands/requests.py +0 -0
  63. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/commands/transfer.py +0 -0
  64. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/compatibility.py +0 -0
  65. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/config.py +0 -0
  66. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/dates.py +0 -0
  67. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/devtools.py +0 -0
  68. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/grib.py +0 -0
  69. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/grids.py +0 -0
  70. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/hindcasts.py +0 -0
  71. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/humanize.py +0 -0
  72. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/logs.py +0 -0
  73. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/mars/__init__.py +0 -0
  74. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/mars/mars.yaml +0 -0
  75. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/mars/requests.py +0 -0
  76. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/mlflow/__init__.py +0 -0
  77. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/mlflow/client.py +0 -0
  78. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/mlflow/utils.py +0 -0
  79. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/provenance.py +0 -0
  80. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/registry.py +0 -0
  81. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/remote/__init__.py +0 -0
  82. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/remote/ssh.py +0 -0
  83. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/rules.py +0 -0
  84. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/s3.py +0 -0
  85. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/sanitise.py +0 -0
  86. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/sanitize.py +0 -0
  87. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/schemas/__init__.py +0 -0
  88. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/schemas/errors.py +0 -0
  89. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/testing.py +0 -0
  90. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/text.py +0 -0
  91. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi/utils/timer.py +0 -0
  92. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi_utils.egg-info/SOURCES.txt +0 -0
  93. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi_utils.egg-info/dependency_links.txt +0 -0
  94. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi_utils.egg-info/entry_points.txt +0 -0
  95. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi_utils.egg-info/requires.txt +0 -0
  96. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/src/anemoi_utils.egg-info/top_level.txt +0 -0
  97. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test-transfer-data/directory/b/c/x +0 -0
  98. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test-transfer-data/directory/b/y +0 -0
  99. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test-transfer-data/directory/exotic filename ;^/"'[=.,#]()/303/252/303/274/303/247/303/262/342/234/205.txt" +0 -0
  100. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test-transfer-data/directory/z +0 -0
  101. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test-transfer-data/file +0 -0
  102. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test_caching.py +0 -0
  103. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test_checkpoints.py +0 -0
  104. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test_compatibility.py +0 -0
  105. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test_dates.py +0 -0
  106. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test_frequency.py +0 -0
  107. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test_mlflow_client.py +0 -0
  108. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test_provenance.py +0 -0
  109. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test_registry.py +0 -0
  110. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test_remote.py +0 -0
  111. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test_s3.py +0 -0
  112. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test_sanitise.py +0 -0
  113. {anemoi_utils-0.4.36 → anemoi_utils-0.4.37}/tests/test_utils.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.4.37"
3
+ }
@@ -8,6 +8,19 @@ 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.37](https://github.com/ecmwf/anemoi-utils/compare/0.4.36...0.4.37) (2025-09-30)
12
+
13
+
14
+ ### Features
15
+
16
+ * **mlflow auth:** Support for multiple servers ([#217](https://github.com/ecmwf/anemoi-utils/issues/217)) ([8ccfb1a](https://github.com/ecmwf/anemoi-utils/commit/8ccfb1ab063cccfec5852c386580036286b097c6))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * 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))
22
+ * 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))
23
+
11
24
  ## [0.4.36](https://github.com/ecmwf/anemoi-utils/compare/0.4.35...0.4.36) (2025-09-22)
12
25
 
13
26
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anemoi-utils
3
- Version: 0.4.36
3
+ Version: 0.4.37
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.37'
32
+ __version_tuple__ = version_tuple = (0, 4, 37)
33
33
 
34
- __commit_id__ = commit_id = 'g67b40f99c'
34
+ __commit_id__ = commit_id = 'gceecf1ec8'
@@ -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
 
@@ -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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anemoi-utils
3
- Version: 0.4.36
3
+ Version: 0.4.37
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
@@ -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