anemoi-utils 0.4.27__tar.gz → 0.4.29__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 (108) hide show
  1. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.github/pull_request_template.md +2 -0
  2. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.pre-commit-config.yaml +2 -2
  3. anemoi_utils-0.4.29/.release-please-manifest.json +3 -0
  4. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/CHANGELOG.md +28 -0
  5. {anemoi_utils-0.4.27/src/anemoi_utils.egg-info → anemoi_utils-0.4.29}/PKG-INFO +7 -2
  6. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/pyproject.toml +4 -2
  7. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/_version.py +2 -2
  8. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/dates.py +11 -0
  9. anemoi_utils-0.4.29/src/anemoi/utils/mlflow/__init__.py +8 -0
  10. anemoi_utils-0.4.29/src/anemoi/utils/mlflow/auth.py +255 -0
  11. anemoi_utils-0.4.29/src/anemoi/utils/mlflow/client.py +76 -0
  12. anemoi_utils-0.4.29/src/anemoi/utils/mlflow/utils.py +44 -0
  13. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/sanitise.py +35 -45
  14. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29/src/anemoi_utils.egg-info}/PKG-INFO +7 -2
  15. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi_utils.egg-info/SOURCES.txt +7 -1
  16. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi_utils.egg-info/requires.txt +7 -1
  17. anemoi_utils-0.4.29/tests/test_mlflow_auth.py +162 -0
  18. anemoi_utils-0.4.29/tests/test_mlflow_client.py +47 -0
  19. anemoi_utils-0.4.27/tests/test_sanetise.py → anemoi_utils-0.4.29/tests/test_sanitise.py +11 -0
  20. anemoi_utils-0.4.27/.release-please-manifest.json +0 -3
  21. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.gitattributes +0 -0
  22. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.github/CODEOWNERS +0 -0
  23. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.github/ci-hpc-config.yml +0 -0
  24. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.github/dependabot.yml +0 -0
  25. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.github/labeler.yml +0 -0
  26. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.github/workflows/downstream-ci-hpc.yml +0 -0
  27. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.github/workflows/pr-conventional-commit.yml +0 -0
  28. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.github/workflows/pr-label-conventional-commits.yml +0 -0
  29. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.github/workflows/pr-label-file-based.yml +0 -0
  30. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.github/workflows/pr-label-public.yml +0 -0
  31. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.github/workflows/python-publish.yml +0 -0
  32. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.github/workflows/python-pull-request.yml +0 -0
  33. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.github/workflows/readthedocs-pr-update.yml +0 -0
  34. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.github/workflows/release-please.yml +0 -0
  35. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.gitignore +0 -0
  36. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.readthedocs.yaml +0 -0
  37. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/.release-please-config.json +0 -0
  38. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/CONTRIBUTORS.md +0 -0
  39. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/LICENSE +0 -0
  40. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/README.md +0 -0
  41. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/Makefile +0 -0
  42. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/_static/logo.png +0 -0
  43. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/_static/style.css +0 -0
  44. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/_templates/.gitkeep +0 -0
  45. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/_templates/apidoc/package.rst.jinja +0 -0
  46. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/conf.py +0 -0
  47. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/index.rst +0 -0
  48. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/installing.rst +0 -0
  49. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/modules/checkpoints.rst +0 -0
  50. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/modules/config.rst +0 -0
  51. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/modules/dates.rst +0 -0
  52. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/modules/grib.rst +0 -0
  53. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/modules/humanize.rst +0 -0
  54. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/modules/provenance.rst +0 -0
  55. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/modules/s3.rst +0 -0
  56. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/modules/testing.rst +0 -0
  57. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/modules/text.rst +0 -0
  58. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/docs/scripts/api_build.sh +0 -0
  59. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/setup.cfg +0 -0
  60. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/__init__.py +0 -0
  61. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/__main__.py +0 -0
  62. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/caching.py +0 -0
  63. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/checkpoints.py +0 -0
  64. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/cli.py +0 -0
  65. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/commands/__init__.py +0 -0
  66. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/commands/config.py +0 -0
  67. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/commands/metadata.py +0 -0
  68. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/commands/requests.py +0 -0
  69. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/commands/transfer.py +0 -0
  70. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/compatibility.py +0 -0
  71. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/config.py +0 -0
  72. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/devtools.py +0 -0
  73. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/grib.py +0 -0
  74. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/grids.py +0 -0
  75. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/hindcasts.py +0 -0
  76. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/humanize.py +0 -0
  77. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/logs.py +0 -0
  78. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/mars/__init__.py +0 -0
  79. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/mars/mars.yaml +0 -0
  80. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/mars/requests.py +0 -0
  81. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/provenance.py +0 -0
  82. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/registry.py +0 -0
  83. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/remote/__init__.py +0 -0
  84. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/remote/s3.py +0 -0
  85. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/remote/ssh.py +0 -0
  86. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/rules.py +0 -0
  87. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/s3.py +0 -0
  88. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/sanitize.py +0 -0
  89. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/schemas/__init__.py +0 -0
  90. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/schemas/errors.py +0 -0
  91. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/testing.py +0 -0
  92. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/text.py +0 -0
  93. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi/utils/timer.py +0 -0
  94. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi_utils.egg-info/dependency_links.txt +0 -0
  95. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi_utils.egg-info/entry_points.txt +0 -0
  96. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/src/anemoi_utils.egg-info/top_level.txt +0 -0
  97. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/tests/test-transfer-data/directory/b/c/x +0 -0
  98. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/tests/test-transfer-data/directory/b/y +0 -0
  99. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/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.27 → anemoi_utils-0.4.29}/tests/test-transfer-data/directory/z +0 -0
  101. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/tests/test-transfer-data/file +0 -0
  102. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/tests/test_caching.py +0 -0
  103. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/tests/test_compatibility.py +0 -0
  104. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/tests/test_dates.py +0 -0
  105. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/tests/test_frequency.py +0 -0
  106. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/tests/test_provenance.py +0 -0
  107. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/tests/test_remote.py +0 -0
  108. {anemoi_utils-0.4.27 → anemoi_utils-0.4.29}/tests/test_utils.py +0 -0
@@ -11,3 +11,5 @@
11
11
  <!-- Include any additional information, caveats, or considerations that the reviewer should be aware of. -->
12
12
 
13
13
  ***As a contributor to the Anemoi framework, please ensure that your changes include unit tests, updates to any affected dependencies and documentation, and have been tested in a parallel setting (i.e., with multiple GPUs). As a reviewer, you are also responsible for verifying these aspects and requesting changes if they are not adequately addressed. For guidelines about those please refer to https://anemoi.readthedocs.io/en/latest/***
14
+
15
+ By opening this pull request, I affirm that all authors agree to the [Contributor License Agreement.](https://github.com/ecmwf/codex/blob/main/Legal/contributor_license_agreement.md)
@@ -41,7 +41,7 @@ repos:
41
41
  - --profile black
42
42
  - --project anemoi
43
43
  - repo: https://github.com/astral-sh/ruff-pre-commit
44
- rev: v0.11.12
44
+ rev: v0.12.2
45
45
  hooks:
46
46
  - id: ruff
47
47
  args:
@@ -69,7 +69,7 @@ repos:
69
69
  hooks:
70
70
  - id: pyproject-fmt
71
71
  - repo: https://github.com/jshwi/docsig # Check docstrings against function sig
72
- rev: v0.69.3
72
+ rev: v0.70.0
73
73
  hooks:
74
74
  - id: docsig
75
75
  args:
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.4.29"
3
+ }
@@ -8,6 +8,34 @@ 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.29](https://github.com/ecmwf/anemoi-utils/compare/0.4.28...0.4.29) (2025-07-22)
12
+
13
+
14
+ ### Features
15
+
16
+ * Better support for negative timedeltas ([#180](https://github.com/ecmwf/anemoi-utils/issues/180)) ([3f8041a](https://github.com/ecmwf/anemoi-utils/commit/3f8041a46b525b6fcbe6171cd8a8a40ec30b2c1f))
17
+ * **deps:** Use mlflow-skinny instead of mlflow ([#184](https://github.com/ecmwf/anemoi-utils/issues/184)) ([82e5c30](https://github.com/ecmwf/anemoi-utils/commit/82e5c3053962cd8e1e8f6a1ea9e8f92492e497b4))
18
+ * Protect mlflow token file ([#183](https://github.com/ecmwf/anemoi-utils/issues/183)) ([fdf0fc8](https://github.com/ecmwf/anemoi-utils/commit/fdf0fc84ee3e8076928f6c888374cd3aa008023b))
19
+ * **sanitise:** Sanitation level ([#175](https://github.com/ecmwf/anemoi-utils/issues/175)) ([8d85d8f](https://github.com/ecmwf/anemoi-utils/commit/8d85d8fd889bf72b8066cc021d4d7b329a360848))
20
+ * Support negative timedelta ([#178](https://github.com/ecmwf/anemoi-utils/issues/178)) ([546f6ec](https://github.com/ecmwf/anemoi-utils/commit/546f6ec76534cd39094957ce3b57b34f14f7a000))
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * Clean utils ([#185](https://github.com/ecmwf/anemoi-utils/issues/185)) ([de3c7a4](https://github.com/ecmwf/anemoi-utils/commit/de3c7a47f14c258997942564717c480caa124ee6))
26
+
27
+ ## [0.4.28](https://github.com/ecmwf/anemoi-utils/compare/0.4.27...0.4.28) (2025-07-03)
28
+
29
+
30
+ ### Features
31
+
32
+ * Migrate mlflow utils from anemoi-training ([#174](https://github.com/ecmwf/anemoi-utils/issues/174)) ([0b7767b](https://github.com/ecmwf/anemoi-utils/commit/0b7767bc23486b140ad7423e3c5c7d5857cef71c))
33
+
34
+
35
+ ### Bug Fixes
36
+
37
+ * Treat mlflow as an optional dependency ([#177](https://github.com/ecmwf/anemoi-utils/issues/177)) ([feb1088](https://github.com/ecmwf/anemoi-utils/commit/feb1088169a29f42032bf26d5c43f9817557bafc))
38
+
11
39
  ## [0.4.27](https://github.com/ecmwf/anemoi-utils/compare/0.4.26...0.4.27) (2025-06-27)
12
40
 
13
41
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: anemoi-utils
3
- Version: 0.4.27
3
+ Version: 0.4.29
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,13 +250,18 @@ 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-skinny>=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"
256
259
  Provides-Extra: s3
257
260
  Requires-Dist: boto3>1.36; extra == "s3"
258
261
  Provides-Extra: tests
262
+ Requires-Dist: anemoi-utils[mlflow]; extra == "tests"
259
263
  Requires-Dist: pytest; extra == "tests"
264
+ Requires-Dist: pytest-mock>=3; extra == "tests"
260
265
  Provides-Extra: text
261
266
  Requires-Dist: termcolor; extra == "text"
262
267
  Requires-Dist: wcwidth; extra == "text"
@@ -53,7 +53,7 @@ dependencies = [
53
53
  "tqdm",
54
54
  ]
55
55
 
56
- optional-dependencies.all = [ "anemoi-utils[grib,provenance,text,s3]" ]
56
+ optional-dependencies.all = [ "anemoi-utils[grib,provenance,text,s3,mlflow]" ]
57
57
  optional-dependencies.dev = [ "anemoi-utils[all,docs,tests]" ]
58
58
 
59
59
  optional-dependencies.docs = [
@@ -69,13 +69,15 @@ optional-dependencies.docs = [
69
69
 
70
70
  optional-dependencies.grib = [ "requests" ]
71
71
 
72
+ optional-dependencies.mlflow = [ "mlflow-skinny>=2.11.1", "requests" ]
73
+
72
74
  optional-dependencies.provenance = [ "gitpython", "nvsmi" ]
73
75
 
74
76
  optional-dependencies.s3 = [
75
77
  "boto3>1.36",
76
78
  ]
77
79
 
78
- optional-dependencies.tests = [ "pytest" ]
80
+ optional-dependencies.tests = [ "anemoi-utils[mlflow]", "pytest", "pytest-mock>=3" ]
79
81
 
80
82
  optional-dependencies.text = [ "termcolor", "wcwidth" ]
81
83
 
@@ -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.27'
21
- __version_tuple__ = version_tuple = (0, 4, 27)
20
+ __version__ = version = '0.4.29'
21
+ __version_tuple__ = version_tuple = (0, 4, 29)
@@ -199,6 +199,15 @@ def as_timedelta(frequency: Union[int, str, datetime.timedelta]) -> datetime.tim
199
199
  except ValueError:
200
200
  pass
201
201
 
202
+ if frequency.startswith(" ") or frequency.startswith(" "):
203
+ frequency = frequency.strip()
204
+
205
+ if frequency.startswith("-"):
206
+ return -as_timedelta(frequency[1:])
207
+
208
+ if frequency.startswith("+"):
209
+ return as_timedelta(frequency[1:])
210
+
202
211
  if re.match(r"^\d+[hdms]$", frequency, re.IGNORECASE):
203
212
  unit = frequency[-1].lower()
204
213
  v = int(frequency[:-1])
@@ -261,6 +270,8 @@ def frequency_to_string(frequency: datetime.timedelta) -> str:
261
270
  frequency = frequency_to_timedelta(frequency)
262
271
 
263
272
  total_seconds = frequency.total_seconds()
273
+ if total_seconds < 0:
274
+ return f"-{frequency_to_string(-frequency)}"
264
275
  assert int(total_seconds) == total_seconds, total_seconds
265
276
  total_seconds = int(total_seconds)
266
277
 
@@ -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,255 @@
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 config_path
26
+ from ..config import load_config
27
+ from ..config import save_config
28
+ from ..remote import robust
29
+ from ..timer import Timer
30
+
31
+ REFRESH_EXPIRE_DAYS = 29
32
+
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Callable
36
+
37
+
38
+ class TokenAuth:
39
+ """Manage authentication with a keycloak token server."""
40
+
41
+ config_file = "mlflow-token.json"
42
+
43
+ def __init__(
44
+ self,
45
+ url: str,
46
+ enabled: bool = True,
47
+ target_env_var: str = "MLFLOW_TRACKING_TOKEN",
48
+ ) -> None:
49
+ """Initialise the token authentication object.
50
+
51
+ Parameters
52
+ ----------
53
+ url : str
54
+ URL of the authentication server.
55
+ enabled : bool, optional
56
+ Set this to False to turn off authentication, by default True
57
+ target_env_var : str, optional
58
+ The environment variable to store the access token in after authenticating,
59
+ by default `MLFLOW_TRACKING_TOKEN`
60
+
61
+ """
62
+ self.url = url
63
+ self.target_env_var = target_env_var
64
+ self._enabled = enabled
65
+
66
+ config = self.load_config()
67
+
68
+ self._refresh_token = config.get("refresh_token")
69
+ self.refresh_expires = config.get("refresh_expires", 0)
70
+ self.access_token = None
71
+ self.access_expires = 0
72
+
73
+ # the command line tool adds a default handler to the root logger on runtime,
74
+ # so we init our logger here (on runtime, not on import) to avoid duplicate handlers
75
+ self.log = logging.getLogger(__name__)
76
+
77
+ def __call__(self) -> None:
78
+ self.authenticate()
79
+
80
+ @property
81
+ def refresh_token(self) -> str:
82
+ return self._refresh_token
83
+
84
+ @refresh_token.setter
85
+ def refresh_token(self, value: str) -> None:
86
+ self._refresh_token = value
87
+ self.refresh_expires = time.time() + (REFRESH_EXPIRE_DAYS * 86400) # 86400 seconds in a day
88
+
89
+ @staticmethod
90
+ def load_config() -> dict:
91
+ path = config_path(TokenAuth.config_file)
92
+
93
+ if not os.path.exists(path):
94
+ save_config(TokenAuth.config_file, {})
95
+
96
+ if os.path.exists(path) and os.stat(path).st_mode & 0o777 != 0o600:
97
+ os.chmod(path, 0o600)
98
+
99
+ return load_config(TokenAuth.config_file)
100
+
101
+ def enabled(fn: Callable) -> Callable: # noqa: N805
102
+ """Decorator to call or ignore a function based on the `enabled` flag."""
103
+
104
+ @wraps(fn)
105
+ def _wrapper(self: TokenAuth, *args, **kwargs) -> Callable | None:
106
+ if self._enabled:
107
+ return fn(self, *args, **kwargs)
108
+ return None
109
+
110
+ return _wrapper
111
+
112
+ @enabled
113
+ def login(self, force_credentials: bool = False, **kwargs: dict) -> None:
114
+ """Acquire a new refresh token and save it to disk.
115
+
116
+ If an existing valid refresh token is already on disk it will be used.
117
+ If not, or the token has expired, the user will be asked to obtain one from the API.
118
+
119
+ Refresh token expiry time is set in the `REFRESH_EXPIRE_DAYS` constant (default 29 days).
120
+
121
+ This function should be called once, interactively, right before starting a training run.
122
+
123
+ Parameters
124
+ ----------
125
+ force_credentials : bool, optional
126
+ Force a credential login even if a refreh token is available, by default False.
127
+ kwargs : dict
128
+ Additional keyword arguments.
129
+
130
+ Raises
131
+ ------
132
+ RuntimeError
133
+ A new refresh token could not be acquired.
134
+
135
+ """
136
+ del kwargs # unused
137
+ self.log.info("🌐 Logging in to %s", self.url)
138
+ new_refresh_token = None
139
+
140
+ if not force_credentials and self.refresh_token and self.refresh_expires > time.time():
141
+ new_refresh_token = self._token_request(ignore_exc=True).get("refresh_token")
142
+
143
+ if not new_refresh_token:
144
+ self.log.info("📝 Please obtain a seed refresh token from %s/seed", self.url)
145
+ self.log.info("📝 and paste it here (you will not see the output, just press enter after pasting):")
146
+ self.refresh_token = getpass("Refresh Token: ")
147
+
148
+ # perform a new refresh token request to check if the seed refresh token is valid
149
+ new_refresh_token = self._token_request().get("refresh_token")
150
+
151
+ if not new_refresh_token:
152
+ msg = "❌ Failed to log in. Please try again."
153
+ raise RuntimeError(msg)
154
+
155
+ self.refresh_token = new_refresh_token
156
+ self.save()
157
+
158
+ self.log.info("✅ Successfully logged in to MLflow. Happy logging!")
159
+
160
+ @enabled
161
+ def authenticate(self, **kwargs: dict) -> None:
162
+ """Check the access token and refresh it if necessary. A new refresh token will also be acquired upon refresh.
163
+
164
+ This requires a valid refresh token to be available, obtained from the `login` method.
165
+
166
+ The access token is stored in memory and in an environment variable.
167
+ If the access token is still valid, this function does nothing.
168
+
169
+ This function should be called before every MLflow API request.
170
+
171
+ Raises
172
+ ------
173
+ RuntimeError
174
+ No refresh token is available or the token request failed.
175
+
176
+ """
177
+ del kwargs # unused
178
+ if self.access_expires > time.time():
179
+ return
180
+
181
+ if not self.refresh_token or self.refresh_expires < time.time():
182
+ msg = "You are not logged in to MLflow. Please log in first."
183
+ raise RuntimeError(msg)
184
+
185
+ with Timer("Access token refreshed", self.log):
186
+ response = self._token_request()
187
+
188
+ self.access_token = response.get("access_token")
189
+ self.access_expires = time.time() + (response.get("expires_in") * 0.7) # bit of buffer
190
+ self.refresh_token = response.get("refresh_token")
191
+
192
+ os.environ[self.target_env_var] = self.access_token
193
+
194
+ @enabled
195
+ def save(self, **kwargs: dict) -> None:
196
+ """Save the latest refresh token to disk."""
197
+ del kwargs # unused
198
+ if not self.refresh_token:
199
+ self.log.warning("No refresh token to save.")
200
+ return
201
+
202
+ config = {
203
+ "url": self.url,
204
+ "refresh_token": self.refresh_token,
205
+ "refresh_expires": self.refresh_expires,
206
+ }
207
+ save_config(self.config_file, config)
208
+
209
+ expire_date = datetime.fromtimestamp(self.refresh_expires, tz=timezone.utc)
210
+ self.log.info(
211
+ "Your MLflow login token is valid until %s UTC",
212
+ expire_date.strftime("%Y-%m-%d %H:%M:%S"),
213
+ )
214
+
215
+ def _token_request(
216
+ self,
217
+ ignore_exc: bool = False,
218
+ ) -> dict:
219
+ path = "refreshtoken"
220
+ payload = {"refresh_token": self.refresh_token}
221
+
222
+ try:
223
+ response = self._request(path, payload)
224
+ except Exception:
225
+ if ignore_exc:
226
+ return {}
227
+ raise
228
+
229
+ return response
230
+
231
+ def _request(self, path: str, payload: dict) -> dict:
232
+
233
+ headers = {
234
+ "Content-Type": "application/x-www-form-urlencoded",
235
+ }
236
+
237
+ try:
238
+ response = robust(requests.post)(
239
+ f"{self.url}/{path}",
240
+ headers=headers,
241
+ json=payload,
242
+ timeout=60,
243
+ )
244
+ response.raise_for_status()
245
+ response_json = response.json()
246
+
247
+ if response_json.get("status", "") != "OK":
248
+ error_description = response_json.get("response", "Error acquiring token.")
249
+ msg = f"❌ {error_description}"
250
+ raise RuntimeError(msg)
251
+
252
+ return response_json["response"]
253
+ except HTTPError:
254
+ self.log.exception("HTTP error occurred")
255
+ 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,44 @@
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 os
12
+
13
+ import requests
14
+
15
+ from ..remote import robust
16
+
17
+
18
+ def health_check(tracking_uri: str) -> None:
19
+ """Query the health endpoint of an MLflow server.
20
+
21
+ If the server is not reachable, raise an error and remind the user that authentication may be required.
22
+
23
+ Raises
24
+ ------
25
+ ConnectionError
26
+ If the server is not reachable.
27
+
28
+ """
29
+ token = os.getenv("MLFLOW_TRACKING_TOKEN")
30
+
31
+ headers = {"Authorization": f"Bearer {token}"}
32
+ response = robust(requests.get, retry_after=30, maximum_tries=10)(
33
+ f"{tracking_uri}/health",
34
+ headers=headers,
35
+ timeout=60,
36
+ )
37
+
38
+ if response.text == "OK":
39
+ return
40
+
41
+ error_msg = f"Could not connect to MLflow server at {tracking_uri}. "
42
+ if not token:
43
+ error_msg += "The server may require authentication, did you forget to turn it on?"
44
+ raise ConnectionError(error_msg)