anemoi-utils 0.4.13__tar.gz → 0.4.15__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 (96) hide show
  1. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.github/workflows/downstream-ci-hpc.yml +3 -3
  2. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.gitignore +1 -0
  3. anemoi_utils-0.4.15/.release-please-manifest.json +3 -0
  4. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/CHANGELOG.md +14 -0
  5. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/PKG-INFO +3 -2
  6. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/_version.py +2 -2
  7. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/devtools.py +4 -6
  8. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/humanize.py +4 -10
  9. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/provenance.py +4 -4
  10. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/registry.py +125 -29
  11. anemoi_utils-0.4.15/src/anemoi/utils/testing.py +182 -0
  12. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi_utils.egg-info/PKG-INFO +3 -2
  13. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi_utils.egg-info/SOURCES.txt +1 -0
  14. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/tests/test_remote.py +6 -0
  15. anemoi_utils-0.4.13/.release-please-manifest.json +0 -3
  16. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.gitattributes +0 -0
  17. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.github/CODEOWNERS +0 -0
  18. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.github/ci-hpc-config.yml +0 -0
  19. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.github/dependabot.yml +0 -0
  20. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.github/labeler.yml +0 -0
  21. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.github/pull_request_template.md +0 -0
  22. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.github/release.yml +0 -0
  23. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.github/workflows/pr-conventional-commit.yml +0 -0
  24. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.github/workflows/pr-label-conventional-commits.yml +0 -0
  25. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.github/workflows/pr-label-file-based.yml +0 -0
  26. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.github/workflows/pr-label-public.yml +0 -0
  27. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.github/workflows/python-publish.yml +0 -0
  28. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.github/workflows/python-pull-request.yml +0 -0
  29. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.github/workflows/readthedocs-pr-update.yml +0 -0
  30. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.github/workflows/release-please.yml +0 -0
  31. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.pre-commit-config.yaml +0 -0
  32. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.readthedocs.yaml +0 -0
  33. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/.release-please-config.json +0 -0
  34. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/CONTRIBUTORS.md +0 -0
  35. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/LICENSE +0 -0
  36. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/README.md +0 -0
  37. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/docs/Makefile +0 -0
  38. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/docs/_static/logo.png +0 -0
  39. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/docs/_static/style.css +0 -0
  40. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/docs/_templates/.gitkeep +0 -0
  41. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/docs/conf.py +0 -0
  42. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/docs/index.rst +0 -0
  43. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/docs/installing.rst +0 -0
  44. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/docs/modules/checkpoints.rst +0 -0
  45. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/docs/modules/config.rst +0 -0
  46. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/docs/modules/dates.rst +0 -0
  47. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/docs/modules/grib.rst +0 -0
  48. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/docs/modules/humanize.rst +0 -0
  49. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/docs/modules/provenance.rst +0 -0
  50. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/docs/modules/s3.rst +0 -0
  51. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/docs/modules/text.rst +0 -0
  52. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/pyproject.toml +0 -0
  53. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/setup.cfg +0 -0
  54. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/__init__.py +0 -0
  55. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/__main__.py +0 -0
  56. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/caching.py +0 -0
  57. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/checkpoints.py +0 -0
  58. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/cli.py +0 -0
  59. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/commands/__init__.py +0 -0
  60. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/commands/config.py +0 -0
  61. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/commands/requests.py +0 -0
  62. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/compatibility.py +0 -0
  63. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/config.py +0 -0
  64. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/dates.py +0 -0
  65. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/grib.py +0 -0
  66. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/grids.py +0 -0
  67. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/hindcasts.py +0 -0
  68. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/logs.py +0 -0
  69. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/mars/__init__.py +0 -0
  70. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/mars/mars.yaml +0 -0
  71. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/mars/requests.py +0 -0
  72. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/remote/__init__.py +0 -0
  73. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/remote/s3.py +0 -0
  74. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/remote/ssh.py +0 -0
  75. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/s3.py +0 -0
  76. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/sanitise.py +0 -0
  77. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/sanitize.py +0 -0
  78. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/text.py +0 -0
  79. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi/utils/timer.py +0 -0
  80. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi_utils.egg-info/dependency_links.txt +0 -0
  81. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi_utils.egg-info/entry_points.txt +0 -0
  82. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi_utils.egg-info/requires.txt +0 -0
  83. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/src/anemoi_utils.egg-info/top_level.txt +0 -0
  84. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/tests/test-transfer-data/directory/b/c/x +0 -0
  85. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/tests/test-transfer-data/directory/b/y +0 -0
  86. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/tests/test-transfer-data/directory/exotic filename ;^/"'[=.,#]()/303/252/303/274/303/247/303/262/342/234/205.txt" +0 -0
  87. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/tests/test-transfer-data/directory/z +0 -0
  88. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/tests/test-transfer-data/file +0 -0
  89. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/tests/test_caching.py +0 -0
  90. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/tests/test_compatibility.py +0 -0
  91. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/tests/test_dates.py +0 -0
  92. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/tests/test_frequency.py +0 -0
  93. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/tests/test_grids.py +0 -0
  94. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/tests/test_provenance.py +0 -0
  95. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/tests/test_sanetise.py +0 -0
  96. {anemoi_utils-0.4.13 → anemoi_utils-0.4.15}/tests/test_utils.py +0 -0
@@ -1,5 +1,5 @@
1
1
  # This workflow triggers tests on dependent packages.
2
- # The dependency tree itself is defined in ecmwf-actions/downstream-ci/
2
+ # The dependency tree itself is defined in ecmwf/downstream-ci/
3
3
  name: Test downstream dependent packages on HPC
4
4
 
5
5
  on:
@@ -38,7 +38,7 @@ jobs:
38
38
  downstream-ci:
39
39
  name: downstream-ci
40
40
  if: ${{ !github.event.pull_request.head.repo.fork && github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci' }}
41
- uses: ecmwf-actions/downstream-ci/.github/workflows/downstream-ci.yml@main
41
+ uses: ecmwf/downstream-ci/.github/workflows/downstream-ci.yml@main
42
42
  with:
43
43
  anemoi-utils: ecmwf/anemoi-utils@${{ github.event.pull_request.head.sha || github.sha }}
44
44
  codecov_upload: true
@@ -54,7 +54,7 @@ jobs:
54
54
  # downstream-ci-hpc:
55
55
  # name: downstream-ci-hpc
56
56
  # if: ${{ !github.event.pull_request.head.repo.fork && github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci' }}
57
- # uses: ecmwf-actions/downstream-ci/.github/workflows/downstream-ci-hpc.yml@main
57
+ # uses: ecmwf/downstream-ci/.github/workflows/downstream-ci-hpc.yml@main
58
58
  # with:
59
59
  # anemoi-utils: ecmwf/anemoi-utils@${{ github.event.pull_request.head.sha || github.sha }}
60
60
  # secrets: inherit
@@ -135,3 +135,4 @@ _version.py
135
135
  *.to_upload
136
136
  tempCodeRunnerFile.python
137
137
  Untitled-*.py
138
+ .*cache/
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.4.15"
3
+ }
@@ -8,6 +8,20 @@ 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.15](https://github.com/ecmwf/anemoi-utils/compare/0.4.14...0.4.15) (2025-03-21)
12
+
13
+
14
+ ### Features
15
+
16
+ * accept hyphens in factory names ([#116](https://github.com/ecmwf/anemoi-utils/issues/116)) ([ada96e9](https://github.com/ecmwf/anemoi-utils/commit/ada96e911b592ff9d95d3a93fff5a6aa21cdebbe))
17
+
18
+ ## [0.4.14](https://github.com/ecmwf/anemoi-utils/compare/0.4.13...0.4.14) (2025-03-21)
19
+
20
+
21
+ ### Bug Fixes
22
+
23
+ * plugin support ([#110](https://github.com/ecmwf/anemoi-utils/issues/110)) ([329395a](https://github.com/ecmwf/anemoi-utils/commit/329395a5870cbf59bacb39cb5afea6b91c465b07))
24
+
11
25
  ## [0.4.13](https://github.com/ecmwf/anemoi-utils/compare/0.4.12...0.4.13) (2025-03-14)
12
26
 
13
27
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: anemoi-utils
3
- Version: 0.4.13
3
+ Version: 0.4.15
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
@@ -256,3 +256,4 @@ Requires-Dist: pytest; extra == "tests"
256
256
  Provides-Extra: text
257
257
  Requires-Dist: termcolor; extra == "text"
258
258
  Requires-Dist: wcwidth; extra == "text"
259
+ Dynamic: license-file
@@ -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.13'
21
- __version_tuple__ = version_tuple = (0, 4, 13)
20
+ __version__ = version = '0.4.15'
21
+ __version_tuple__ = version_tuple = (0, 4, 15)
@@ -81,13 +81,11 @@ def plot_values(
81
81
  ax.add_feature(cfeature.BORDERS, linestyle=":")
82
82
 
83
83
  missing_values = np.isnan(values)
84
-
85
84
  if missing_value is None:
86
- values = values[~missing_values]
87
- longitudes = longitudes[~missing_values]
88
- latitudes = latitudes[~missing_values]
89
- else:
90
- values = np.where(missing_values, missing_value, values)
85
+ min = np.nanmin(values)
86
+ missing_value = min - np.abs(min) * 0.001
87
+
88
+ values = np.where(missing_values, missing_value, values)
91
89
 
92
90
  if max_value is not None:
93
91
  values = np.where(values > max_value, max_value, values)
@@ -48,7 +48,7 @@ def bytes_to_human(n: float) -> str:
48
48
  """
49
49
  if n < 0:
50
50
  sign = "-"
51
- n -= 0
51
+ n = -n
52
52
  else:
53
53
  sign = ""
54
54
 
@@ -411,15 +411,9 @@ def when(
411
411
  if years > 1:
412
412
  return _("%d years" % (years,))
413
413
 
414
- month = then.month
415
- if now.year != then.year:
416
- month -= 12
417
-
418
- d = abs(now.month - month)
419
- if d >= 12:
420
- return _("a year")
421
- else:
422
- return _("%d month%s" % (d, _plural(d)))
414
+ delta = abs(now - then)
415
+ if delta.days > 1 and delta.days < 30:
416
+ return _("%d days" % (delta.days,))
423
417
 
424
418
  return "on %s %d %s %d" % (
425
419
  DOW[then.weekday()],
@@ -10,10 +10,10 @@
10
10
 
11
11
  """Collect information about the current environment, like:
12
12
 
13
- - The Python version
14
- - The versions of the modules which are currently loaded
15
- - The git information for the modules which are currently loaded from a git repository
16
- - ...
13
+ - The Python version
14
+ - The versions of the modules which are currently loaded
15
+ - The git information for the modules which are currently loaded from a git repository
16
+ - ...
17
17
  """
18
18
 
19
19
  import datetime
@@ -12,9 +12,12 @@ import importlib
12
12
  import logging
13
13
  import os
14
14
  import sys
15
+ import warnings
16
+ from functools import cached_property
15
17
  from typing import Any
16
18
  from typing import Callable
17
19
  from typing import Dict
20
+ from typing import List
18
21
  from typing import Optional
19
22
  from typing import Union
20
23
 
@@ -22,6 +25,8 @@ import entrypoints
22
25
 
23
26
  LOG = logging.getLogger(__name__)
24
27
 
28
+ DEBUG_ANEMOI_REGISTRY = int(os.environ.get("DEBUG_ANEMOI_REGISTRY", "0"))
29
+
25
30
 
26
31
  class Wrapper:
27
32
  """A wrapper for the registry.
@@ -55,6 +60,22 @@ class Wrapper:
55
60
  return factory
56
61
 
57
62
 
63
+ class Error:
64
+ """An error class. Used in place of a plugin that failed to load.
65
+
66
+ Parameters
67
+ ----------
68
+ error : Exception
69
+ The error.
70
+ """
71
+
72
+ def __init__(self, error: Exception):
73
+ self.error = error
74
+
75
+ def __call__(self, *args, **kwargs):
76
+ raise self.error
77
+
78
+
58
79
  _BY_KIND = {}
59
80
 
60
81
 
@@ -67,13 +88,17 @@ class Registry:
67
88
  The package name.
68
89
  key : str, optional
69
90
  The key to use for the registry, by default "_type".
91
+ api_version : str, optional
92
+ The API version, by default '1.0.0'.
70
93
  """
71
94
 
72
- def __init__(self, package: str, key: str = "_type"):
95
+ def __init__(self, package: str, key: str = "_type", api_version: str = "1.0.0"):
73
96
  self.package = package
74
- self.registered = {}
97
+ self.__registered = {}
98
+ self._sources = {}
75
99
  self.kind = package.split(".")[-1]
76
100
  self.key = key
101
+ self.api_version = api_version
77
102
  _BY_KIND[self.kind] = self
78
103
 
79
104
  @classmethod
@@ -92,7 +117,9 @@ class Registry:
92
117
  """
93
118
  return _BY_KIND.get(kind)
94
119
 
95
- def register(self, name: str, factory: Optional[Callable] = None) -> Optional[Wrapper]:
120
+ def register(
121
+ self, name: str, factory: Optional[Callable] = None, source: Optional[Any] = None
122
+ ) -> Optional[Wrapper]:
96
123
  """Register a factory with the registry.
97
124
 
98
125
  Parameters
@@ -101,19 +128,31 @@ class Registry:
101
128
  The name of the factory.
102
129
  factory : Callable, optional
103
130
  The factory to register, by default None.
131
+ source : Any, optional
132
+ The source of the factory, by default None.
104
133
 
105
134
  Returns
106
135
  -------
107
136
  Wrapper, optional
108
137
  A wrapper if the factory is None, otherwise None.
109
138
  """
139
+
140
+ name = name.replace("_", "-")
141
+
110
142
  if factory is None:
143
+ # This happens when the @register decorator is used
111
144
  return Wrapper(name, self)
112
145
 
113
- self.registered[name] = factory
146
+ if source is None:
147
+ source = getattr(factory, "_source") if hasattr(factory, "_source") else factory
148
+
149
+ if name in self.__registered:
150
+ warnings.warn(f"Factory '{name}' is already registered in {self.package}")
151
+ warnings.warn(f"Existing: {self._sources[name]}")
152
+ warnings.warn(f"New: {source}")
114
153
 
115
- # def registered(self, name: str):
116
- # return name in self.registered
154
+ self.__registered[name] = factory
155
+ self._sources[name] = source
117
156
 
118
157
  def _load(self, file: str) -> None:
119
158
  """Load a module from a file.
@@ -126,8 +165,33 @@ class Registry:
126
165
  name, _ = os.path.splitext(file)
127
166
  try:
128
167
  importlib.import_module(f".{name}", package=self.package)
129
- except Exception:
130
- LOG.warning(f"Error loading filter '{self.package}.{name}'", exc_info=True)
168
+ except Exception as e:
169
+ if DEBUG_ANEMOI_REGISTRY:
170
+ raise
171
+ self._registered[name] = Error(e)
172
+
173
+ def is_registered(self, name: str) -> bool:
174
+ """Check if a factory is registered.
175
+
176
+ Parameters
177
+ ----------
178
+ name : str
179
+ The name of the factory.
180
+
181
+ Returns
182
+ -------
183
+ bool
184
+ Whether the factory is registered.
185
+ """
186
+
187
+ name = name.replace("_", "-")
188
+
189
+ ok = name in self.factories
190
+ if not ok:
191
+ LOG.error(f"Cannot find '{name}' in {self.package}")
192
+ for e in self.factories:
193
+ LOG.info(f"Registered: {e} ({self._sources.get(e)})")
194
+ return ok
131
195
 
132
196
  def lookup(self, name: str, *, return_none: bool = False) -> Optional[Callable]:
133
197
  """Lookup a factory by name.
@@ -144,9 +208,25 @@ class Registry:
144
208
  Callable, optional
145
209
  The factory if found, otherwise None.
146
210
  """
147
- # print('✅✅✅✅✅✅✅✅✅✅✅✅✅', name, self.registered)
148
- if name in self.registered:
149
- return self.registered[name]
211
+
212
+ name = name.replace("_", "-")
213
+
214
+ if return_none:
215
+ return self.factories.get(name)
216
+
217
+ factory = self.factories.get(name)
218
+ if factory is None:
219
+
220
+ LOG.error(f"Cannot find '{name}' in {self.package}")
221
+ for e in self.factories:
222
+ LOG.info(f"Registered: {e} ({self._sources.get(e)})")
223
+
224
+ raise ValueError(f"Cannot find '{name}' in {self.package}")
225
+
226
+ return factory
227
+
228
+ @cached_property
229
+ def factories(self) -> Dict[str, Callable]:
150
230
 
151
231
  directory = sys.modules[self.package].__path__[0]
152
232
 
@@ -167,25 +247,41 @@ class Registry:
167
247
  if file.endswith(".py"):
168
248
  self._load(file)
169
249
 
170
- entrypoint_group = f"anemoi.{self.kind}"
171
- for entry_point in entrypoints.get_group_all(entrypoint_group):
172
- if entry_point.name == name:
173
- if name in self.registered:
174
- LOG.warning(
175
- f"Overwriting builtin '{name}' from {self.package} with plugin '{entry_point.module_name}'"
176
- )
177
- self.registered[name] = entry_point.load()
250
+ bits = self.package.split(".")
251
+ # We assume a name like anemoi.datasets.create.sources, with kind = sources
252
+ assert bits[-1] == self.kind, (self.package, self.kind)
253
+ assert len(bits) > 1, self.package
254
+
255
+ groups = []
256
+ middle = bits[1:-1]
257
+ while True:
258
+ group = ".".join([bits[0], *middle, bits[-1]])
259
+ groups.append(group)
260
+ if len(middle) == 0:
261
+ break
262
+ middle.pop()
263
+
264
+ groups.reverse()
178
265
 
179
- if name not in self.registered:
180
- if return_none:
181
- return None
266
+ LOG.debug("Loading plugins from %s", groups)
182
267
 
183
- for e in self.registered:
184
- LOG.info(f"Registered: {e}")
268
+ for entrypoint_group in groups:
269
+ for entry_point in entrypoints.get_group_all(entrypoint_group):
270
+ source = entry_point.distro
271
+ try:
272
+ self.register(entry_point.name, entry_point.load(), source=source)
273
+ except Exception as e:
274
+ if DEBUG_ANEMOI_REGISTRY:
275
+ raise
276
+ self.register(entry_point.name, Error(e), source=source)
185
277
 
186
- raise ValueError(f"Cannot load '{name}' from {self.package}")
278
+ return self.__registered
187
279
 
188
- return self.registered[name]
280
+ @property
281
+ def registered(self) -> List[str]:
282
+ """Get the registered factories."""
283
+
284
+ return sorted(self.factories.keys())
189
285
 
190
286
  def create(self, name: str, *args: Any, **kwargs: Any) -> Any:
191
287
  """Create an instance using a factory.
@@ -204,12 +300,12 @@ class Registry:
204
300
  Any
205
301
  The created instance.
206
302
  """
303
+
304
+ name = name.replace("_", "-")
305
+
207
306
  factory = self.lookup(name)
208
307
  return factory(*args, **kwargs)
209
308
 
210
- # def __call__(self, name: str, *args, **kwargs):
211
- # return self.create(name, *args, **kwargs)
212
-
213
309
  def from_config(self, config: Union[str, Dict[str, Any]], *args: Any, **kwargs: Any) -> Any:
214
310
  """Create an instance from a configuration.
215
311
 
@@ -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
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.2
1
+ Metadata-Version: 2.4
2
2
  Name: anemoi-utils
3
- Version: 0.4.13
3
+ Version: 0.4.15
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
@@ -256,3 +256,4 @@ Requires-Dist: pytest; extra == "tests"
256
256
  Provides-Extra: text
257
257
  Requires-Dist: termcolor; extra == "text"
258
258
  Requires-Dist: wcwidth; extra == "text"
259
+ Dynamic: license-file
@@ -59,6 +59,7 @@ src/anemoi/utils/registry.py
59
59
  src/anemoi/utils/s3.py
60
60
  src/anemoi/utils/sanitise.py
61
61
  src/anemoi/utils/sanitize.py
62
+ src/anemoi/utils/testing.py
62
63
  src/anemoi/utils/text.py
63
64
  src/anemoi/utils/timer.py
64
65
  src/anemoi/utils/commands/__init__.py
@@ -7,12 +7,14 @@
7
7
 
8
8
  import os
9
9
  import shutil
10
+ import sys
10
11
 
11
12
  import pytest
12
13
 
13
14
  from anemoi.utils.remote import TransferMethodNotImplementedError
14
15
  from anemoi.utils.remote import _find_transfer_class
15
16
  from anemoi.utils.remote import transfer
17
+ from anemoi.utils.testing import packages_installed
16
18
 
17
19
  IN_CI = (os.environ.get("GITHUB_WORKFLOW") is not None) or (os.environ.get("IN_CI_HPC") is not None)
18
20
 
@@ -112,6 +114,7 @@ def test_transfer_find_none(source: str, target: str) -> None:
112
114
 
113
115
 
114
116
  @pytest.mark.skipif(IN_CI, reason="Test requires access to S3")
117
+ @pytest.mark.skipif(not packages_installed("boto3"), reason="boto3 is not installed")
115
118
  def test_transfer_zarr_s3_to_local(tmpdir: pytest.TempPathFactory) -> None:
116
119
  """Test transferring a Zarr file from S3 to local.
117
120
 
@@ -132,6 +135,7 @@ def test_transfer_zarr_s3_to_local(tmpdir: pytest.TempPathFactory) -> None:
132
135
 
133
136
 
134
137
  @pytest.mark.skipif(IN_CI, reason="Test requires access to S3")
138
+ @pytest.mark.skipif(not packages_installed("boto3"), reason="boto3 is not installed")
135
139
  def test_transfer_zarr_local_to_s3(tmpdir: pytest.TempPathFactory) -> None:
136
140
  """Test transferring a Zarr file from local to S3.
137
141
 
@@ -193,6 +197,7 @@ def compare(local1: str, local2: str) -> None:
193
197
 
194
198
 
195
199
  @pytest.mark.skipif(IN_CI, reason="Test requires access to S3")
200
+ @pytest.mark.skipif(not packages_installed("boto3"), reason="boto3 is not installed")
196
201
  @pytest.mark.parametrize("path", ["directory/", "file"])
197
202
  def test_transfer_local_to_s3_to_local(path: str) -> None:
198
203
  """Test transferring a file or directory from local to S3 and back to local.
@@ -224,6 +229,7 @@ def test_transfer_local_to_s3_to_local(path: str) -> None:
224
229
 
225
230
 
226
231
  @pytest.mark.skipif(IN_CI, reason="Test requires ssh access to localhost")
232
+ @pytest.mark.skipif(sys.platform == "darwin", reason="Does not work on MacOS")
227
233
  @pytest.mark.parametrize("path", ["directory", "file"])
228
234
  @pytest.mark.parametrize("temporary_target", [True, False])
229
235
  def test_transfer_local_to_ssh(path: str, temporary_target: bool) -> None:
@@ -1,3 +0,0 @@
1
- {
2
- ".": "0.4.13"
3
- }
File without changes
File without changes
File without changes