diff --git a/pyproject.toml b/pyproject.toml index 4e96b4af..b6855414 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ license = "Apache-2.0" license-files = ["LICENSE"] requires-python = ">=3.11" dependencies = [ - "earthkit-data", + "earthkit-data<1", "cloudpickle", "numpy", "xarray", @@ -31,7 +31,7 @@ dependencies = [ "pyzmq", "fire", "orjson", - "qubed>=0.3.0", + "qubed>=0.3.0,<0.4.4", ] # NOTE optional dependencies are not needed for runtime and would only slow down new worker spin ups etc optional-dependencies.vizualize = [ diff --git a/src/earthkit/workflows/fluent.py b/src/earthkit/workflows/fluent.py index 84ab841a..83b99d08 100644 --- a/src/earthkit/workflows/fluent.py +++ b/src/earthkit/workflows/fluent.py @@ -23,7 +23,7 @@ from ._qubed import expand_as_qube from .graph import Graph, Output from .graph import Node as BaseNode -from .nodetree import combine_by_coords, nodetree_array, nodetree_arrays, nodetree_from_dict, nodetree_new_dimension +from .nodetree import combine_by_coords, nodetree_array, nodetree_arrays, nodetree_dimensions, nodetree_from_dict, nodetree_new_dimension PayloadFunc = Callable | str @@ -314,10 +314,10 @@ def as_action(self, other) -> Action: def join( self, - other_action: "Action", + other_action: Action, dim: str | Coord, match_coord_values: bool = False, - ) -> "Action": + ) -> Action: node_arrays = {} for npath, narray in nodetree_arrays(self.nodes): oarray = nodetree_array(other_action.nodes, npath) @@ -336,12 +336,12 @@ def join( def transform( self, - func: Callable[..., "Action"], + func: Callable[..., Action], params: list, dim: str | Coord, axis: int = 0, path: Optional[str] = None, - ) -> "Action": + ) -> Action: """Create new nodes by applying function on action with different parameters. The result actions from applying function are joined along the specified dimension. @@ -371,8 +371,8 @@ def transform( for index, param in enumerate(params): new_res = func(self.select(path=path), *param) - if dim_name not in new_res.nodes.coords: - new_res._add_dimension(dim_name, dim_values[index], axis, path=path) + if dim_name not in nodetree_dimensions(new_res.nodes): + new_res._add_dimension(dim_name, dim_values[index], axis, path=path, override=True) if res is None: res = new_res else: @@ -389,10 +389,10 @@ def transform( def broadcast( self, - other_action: "Action", + other_action: Action, exclude: list[str] | None = None, path: Optional[str] = None, - ) -> "Action": + ) -> Action: """Broadcast nodes against nodes in other_action Parameters @@ -442,7 +442,7 @@ def expand( axis: int = 0, path: Optional[str] = None, backend_kwargs: dict = {}, - ) -> "Action": + ) -> Action: """Create new dimension in array of nodes of specified size by taking elements of internal data in each node. Indexing is taken along the specified axis dimension of internal data and graph execution will fail if @@ -483,7 +483,7 @@ def map( payload: PayloadFunc | Payload | np.ndarray[Any, Any] | list, yields: Coord | None = None, path: Optional[str] = None, - ) -> "Action": + ) -> Action: """Apply specified payload on all nodes. If argument is an array of payloads, this must be the same size as the array of nodes and each node gets a unique payload from the array @@ -546,7 +546,7 @@ def reduce( batch_size: int = 0, keep_dim: bool = False, path: Optional[str] = None, - ) -> "Action": + ) -> Action: """Reduction operation across the named dimension using the provided function in the payload. If batch_size > 1 and less than the size of the named dimension, the reduction will be computed first in @@ -644,7 +644,7 @@ def flatten( keep_dims: list[str] = [], path: Optional[str] = None, reset_coords: bool = False, - ) -> "Action": + ) -> Action: """Restructures node arrays by flattening arrays along all dims, except keep_dims, for node arrays along path. @@ -674,7 +674,7 @@ def flatten( node_arrays[npath] = node_arrays[npath].reset_coords(to_reset, drop=True) return type(self)(nodetree_from_dict(node_arrays)) - def set_path(self, path: str) -> "Action": + def set_path(self, path: str) -> Action: """Create path for current node array Parameters @@ -689,7 +689,7 @@ def set_path(self, path: str) -> "Action": raise NotImplementedError("Multiple node arrays present, can not set single path") return type(self)(nodetree_from_dict({path: nodetree_array(self.nodes)})) - def create_branches(self, expansion: dict[str, PayloadFunc | Payload]) -> "Action": + def create_branches(self, expansion: dict[str, PayloadFunc | Payload]) -> Action: """Create action containing new node arrays by splitting an existing node array by the specified functions in expansion @@ -714,7 +714,7 @@ def create_branches(self, expansion: dict[str, PayloadFunc | Payload]) -> "Actio node_arrays[path] = nodetree_array(action.map(func).nodes, parent) return type(self)(nodetree_from_dict(node_arrays)) - def combine_branches(self, dim: str, path: Optional[str] = None, force: bool = False) -> "Action": + def combine_branches(self, dim: str, path: Optional[str] = None, force: bool = False) -> Action: """Combine node arrays for leaves along path into a single node array Parameters @@ -751,11 +751,25 @@ def _validate_criteria(self, array: xr.DataArray, criteria: dict) -> tuple[bool, keys = list(criteria.keys()) new_criteria = criteria.copy() for key in keys: + if np.ndim(criteria[key]) == 1 and len(criteria[key]) == 1: + new_criteria[key] = criteria[key][0] if key not in array.dims: - if array.coords.get(key, None) == criteria[key]: - new_criteria.pop(key) - else: + coords = array.coords.get(key, None) + if coords is None: + return False, {} + coords = coords.data.tolist() + if not isinstance(coords, list): + coords = [coords] + if new_criteria[key] not in coords: return False, {} + if len(coords) == 1: + new_criteria.pop(key) + + # Remove from criteria coords that are dependent on other coords in criteria + dependencies = {name: [x for x in val.indexes.keys() if x != name] for name, val in array.coords.items()} + for key, dep in dependencies.items(): + if key in new_criteria and any(d in new_criteria for d in dep): + new_criteria.pop(key) return True, new_criteria def _select( @@ -766,7 +780,7 @@ def _select( expand: bool = False, backend_method: Union[Literal["sel"], Literal["isel"]] = "sel", **kwargs, - ) -> "Action": + ) -> Action: if backend_method not in ["sel", "isel"]: raise ValueError(f"backend_method must be 'sel' or 'isel', got {backend_method}") crit: dict = criteria or {} @@ -777,7 +791,7 @@ def _select( for npath, narray in nodetree_arrays(nodes): # type: ignore[arg-type] if expand: keys = list(crit.keys()) - its = tuple(crit.values()) + its = tuple([x] if np.ndim(x) == 0 else x for x in crit.values()) crits = [dict(zip(keys, vals)) for vals in itertools.product(*its)] else: crits = [crit] @@ -809,7 +823,7 @@ def select( path: Optional[str] = None, expand: bool = False, **kwargs, - ) -> "Action": + ) -> Action: """Create action contaning nodes match selection criteria Parameters @@ -834,7 +848,7 @@ def iselect( path: Optional[str] = None, expand: bool = False, **kwargs, - ) -> "Action": + ) -> Action: """Create action contaning nodes match index selection criteria Parameters @@ -861,7 +875,7 @@ def concatenate( path: Optional[str] = None, backend_kwargs: dict = {}, payload_metadata: dict | None = None, - ) -> "Action": + ) -> Action: return _combine_nodes(self, "concat", dim, batch_size, keep_dim, path, backend_kwargs) @capture_payload_metadata @@ -874,7 +888,7 @@ def stack( path: Optional[str] = None, backend_kwargs: dict = {}, payload_metadata: dict | None = None, - ) -> "Action": + ) -> Action: return _combine_nodes( self, "stack", @@ -894,7 +908,7 @@ def sum( path: Optional[str] = None, backend_kwargs: dict = {}, payload_metadata: dict | None = None, - ) -> "Action": + ) -> Action: return self.reduce( Payload(backends.sum, kwargs=backend_kwargs), dim=dim, @@ -912,7 +926,7 @@ def mean( path: Optional[str] = None, backend_kwargs: dict = {}, payload_metadata: dict | None = None, - ) -> "Action": + ) -> Action: action = self for npath, narray in nodetree_arrays(self.select(path=path).nodes): if len(dim) == 0: @@ -945,7 +959,7 @@ def std( path: Optional[str] = None, backend_kwargs: dict = {}, payload_metadata: dict | None = None, - ) -> "Action": + ) -> Action: action = self for npath, narray in nodetree_arrays(self.select(path=path).nodes): if len(dim) == 0: @@ -986,7 +1000,7 @@ def max( path: Optional[str] = None, backend_kwargs: dict = {}, payload_metadata: dict | None = None, - ) -> "Action": + ) -> Action: return self.reduce( Payload(backends.max, kwargs=backend_kwargs), dim=dim, @@ -1004,7 +1018,7 @@ def min( path: Optional[str] = None, backend_kwargs: dict = {}, payload_metadata: dict | None = None, - ) -> "Action": + ) -> Action: return self.reduce( Payload(backends.min, kwargs=backend_kwargs), dim=dim, @@ -1022,7 +1036,7 @@ def prod( path: Optional[str] = None, backend_kwargs: dict = {}, payload_metadata: dict | None = None, - ) -> "Action": + ) -> Action: return self.reduce( Payload(backends.prod, kwargs=backend_kwargs), dim=dim, @@ -1034,10 +1048,10 @@ def prod( def __two_arg_method( self, method: Callable, - other: "Action | float", + other: Union[Action, float], path: Optional[str] = None, **kwargs, - ) -> "Action": + ) -> Action: if isinstance(other, Action): return self.join(other, "**datatype**", match_coord_values=True).reduce( Payload(method, kwargs=kwargs), dim="**datatype**", path=path @@ -1047,51 +1061,51 @@ def __two_arg_method( @capture_payload_metadata def subtract( self, - other: "Action | float", + other: Union[Action, float], path: Optional[str] = None, backend_kwargs: dict = {}, payload_metadata: dict | None = None, - ) -> "Action": + ) -> Action: return self.__two_arg_method(backends.subtract, other, path=path, **backend_kwargs) @capture_payload_metadata def divide( self, - other: "Action | float", + other: Union[Action, float], path: Optional[str] = None, backend_kwargs: dict = {}, payload_metadata: dict | None = None, - ) -> "Action": + ) -> Action: return self.__two_arg_method(backends.divide, other, path=path, **backend_kwargs) @capture_payload_metadata def add( self, - other: "Action | float", + other: Union[Action, float], path: Optional[str] = None, backend_kwargs: dict = {}, payload_metadata: dict | None = None, - ) -> "Action": + ) -> Action: return self.__two_arg_method(backends.add, other, path=path, **backend_kwargs) @capture_payload_metadata def multiply( self, - other: "Action | float", + other: Union[Action, float], path: Optional[str] = None, backend_kwargs: dict = {}, payload_metadata: dict | None = None, - ) -> "Action": + ) -> Action: return self.__two_arg_method(backends.multiply, other, path=path, **backend_kwargs) @capture_payload_metadata def power( self, - other: "Action | float", + other: Union[Action, float], path: Optional[str] = None, backend_kwargs: dict = {}, payload_metadata: dict | None = None, - ) -> "Action": + ) -> Action: return self.__two_arg_method(backends.pow, other, path=path, **backend_kwargs) def add_attributes(self, attrs: dict): @@ -1124,23 +1138,22 @@ def set_scalar_coords( nodetree[npath] = narray self.nodes = nodetree_from_dict(nodetree) - def _add_dimension(self, name: str, value: Any, axis: Optional[int] = None, path: Optional[str] = None): - new_tree = self.nodes.map_over_datasets(lambda ds: ds.expand_dims({name: [value]}, axis)) - assert isinstance(new_tree, xr.DataTree) - if path is not None: - self.nodes[path] = new_tree[path] - else: - self.nodes = new_tree + def _add_dimension(self, name: str, value: Any, axis: Optional[int] = None, path: Optional[str] = None, override: bool = False): + nodetree = {npath: narray for npath, narray in nodetree_arrays(self.nodes)} + selection = self.select(path=path) + for npath, narray in nodetree_arrays(selection.nodes): + if override and name in narray.dims and len(narray.coords[name]) == 1: + narray = narray.squeeze(name, drop=True) + nodetree[npath] = narray.expand_dims({name: [value]}, axis) + self.nodes = nodetree_from_dict(nodetree) def _squeeze_dimension(self, dim_name: str, drop: bool = False, path: Optional[str] = None): - new_tree = self.nodes.map_over_datasets( - lambda ds: ds.squeeze(dim_name, drop=drop) if dim_name in ds.dims and len(ds.coords[dim_name]) == 1 else ds - ) - assert isinstance(new_tree, xr.DataTree) - if path is not None: - self.nodes[path] = new_tree[path] - else: - self.nodes = new_tree + nodetree = {npath: narray for npath, narray in nodetree_arrays(self.nodes)} + selection = self.select(path=path) + for npath, narray in nodetree_arrays(selection.nodes): + if dim_name in narray.dims and len(narray.coords[dim_name]) == 1: + nodetree[npath] = narray.squeeze(dim_name, drop=drop) + self.nodes = nodetree_from_dict(nodetree) def __getattr__(self, attr): if attr in Action.REGISTRY: diff --git a/tests/earthkit_workflows/backends/test_earthkit.py b/tests/earthkit_workflows/backends/test_earthkit.py index 547b2def..7e427a54 100644 --- a/tests/earthkit_workflows/backends/test_earthkit.py +++ b/tests/earthkit_workflows/backends/test_earthkit.py @@ -55,7 +55,8 @@ def test_multi_arg(func, input_generator, values): concat = backends.concat(*arr) assert len(concat) == 5 assert values(concat).shape == (5, 20) - nested = func(concat) + nested = func(concat, metadata={"key": "value"}) + assert nested.metadata()[0].get("key") == "value" assert values(nested).shape == (1, 20) with pytest.raises(ValueError): diff --git a/tests/earthkit_workflows/test_fluent.py b/tests/earthkit_workflows/test_fluent.py index d273a995..9f32fa1a 100644 --- a/tests/earthkit_workflows/test_fluent.py +++ b/tests/earthkit_workflows/test_fluent.py @@ -388,7 +388,7 @@ def test_flatten_branches(): ({"dim_1": 4}, 1, [(2,)]), ({"path": "/branch1"}, 2, [(3, 4), (3, 4)]), ({"path": "/branch1", "dim_0": 1}, 2, [(4,), (4,)]), - ({"type": "A"}, 1, [(2, 5)]), + ({"type": ["AB"]}, 1, [(2, 5)]), ({"dim_1": 10}, 0, IndexError), ({"dim_0": [2], "dim_1": [0, 4]}, 0, IndexError), ({"dim_0": [2], "dim_1": [0, 4], "expand": True}, 2, [(1, 1), (1, 1)]), @@ -406,7 +406,7 @@ def test_select(selection, num_arrays, shapes_or_error): "/branch1/subbranch2": lambda data: np.where(data == 0, data, np.nan), } ) - subbranches.nodes["/branch2"].coords["type"] = "A" + subbranches.nodes["/branch2"].coords["type"] = "AB" if num_arrays > 0: select_dim = subbranches.sel(**selection) assert len(list(nodetree_arrays(select_dim.nodes))) == num_arrays diff --git a/uv.lock b/uv.lock index 9deb2b4d..9b4a2a6d 100644 --- a/uv.lock +++ b/uv.lock @@ -479,13 +479,12 @@ wheels = [ [[package]] name = "earthkit-data" -version = "0.19.0" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgrib" }, { name = "dask" }, { name = "deprecation" }, - { name = "earthkit-meteo" }, { name = "earthkit-utils" }, { name = "eccodes" }, { name = "entrypoints" }, @@ -502,34 +501,22 @@ dependencies = [ { name = "tqdm" }, { name = "xarray" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/5a/8bb64690a3e920a39ec45c031dde104a3de0ebb86c24376e98cf332ecb9a/earthkit_data-0.19.0.tar.gz", hash = "sha256:2dedfc97a207b28c752ba0ed754313485b46adc40d244062504efb808267c58a", size = 5653925, upload-time = "2026-02-16T10:24:47.766Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/b7/e6b466edd0438b9e3d875a98d0aeb2cdf89acbfd4eaaf5906d2456341e2c/earthkit_data-0.19.0-py3-none-any.whl", hash = "sha256:3697238beeeec94209ecb86ac133bdd6c3f66dad734955edd3c0fd07bc5875e1", size = 396017, upload-time = "2026-02-16T10:24:46.212Z" }, -] - -[[package]] -name = "earthkit-meteo" -version = "0.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "earthkit-utils" }, - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/89/324733e6b2a02d4c336e4579c8899d301ba3b576a8a540071cd095b38183/earthkit_meteo-0.6.1.tar.gz", hash = "sha256:82a89983c8ed9302ca07fc040c2787033c98188ce75fe9332f5ca7808379c60e", size = 370291, upload-time = "2026-02-18T16:04:11.567Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/66/623d201df141f5bb2800d55b6bff0493c7a0d4fd6d1af86ee27c338e4243/earthkit_data-0.20.0.tar.gz", hash = "sha256:17eff8ce863722c016b0d9456d5fe76353dc8c7671ec1091aa7e7189615e80e9", size = 5656819, upload-time = "2026-05-13T12:22:44.707Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/eb/c10deda542516f679a20f1cb641a13094fc9e80bc13e54f4cebcfb9f8173/earthkit_meteo-0.6.1-py3-none-any.whl", hash = "sha256:fcb425e23d1827d2fd85ad4b31bfd41eb8f1dcfc7fb78da4b03d69037e8e1d49", size = 57080, upload-time = "2026-02-18T16:04:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/a00aeee76005909eaa1f7fcb1e1f2333d9f4b53866666e1bb7f9e9e15148/earthkit_data-0.20.0-py3-none-any.whl", hash = "sha256:e1df4d93354bb5bd30b967ad17d52454b4df4d6a37511fc91f950aebba592056", size = 398525, upload-time = "2026-05-13T12:22:43.362Z" }, ] [[package]] name = "earthkit-utils" -version = "0.2.1" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "array-api-compat" }, + { name = "pint" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/6c/f78f7288f3e7a5ba376ec2e35c051f567e796ab7eef75695b02044e81c2c/earthkit_utils-0.2.1.tar.gz", hash = "sha256:f7607aa5d9d418554dc53dc1e140fbd86cdf5f26a6a009168834742ef779f323", size = 24475, upload-time = "2026-02-17T12:58:41.354Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/0b/1a834f5e57294b163aba0713b371810ba28ef5ef73f43ee0cafbeab54e07/earthkit_utils-0.3.0.tar.gz", hash = "sha256:1f49e792683ddd7b40642c41839718d25d101b0f5fbe63a372d0fcd33a7e3cd0", size = 43013, upload-time = "2026-03-24T14:57:10.184Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ac/24/1f869196bceb97a702e938a36f0e24593b640cebe4fffae9b39975d24ab9/earthkit_utils-0.2.1-py3-none-any.whl", hash = "sha256:0a26cad5d6505afb187afd0f3f50545cd81c65a590f41302b292caba179d81da", size = 25410, upload-time = "2026-02-17T12:58:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/eb/cb/0c9f59b0ca2a5ed941b65c52a4a005a7e16511f52fc3965eb45c53565b76/earthkit_utils-0.3.0-py3-none-any.whl", hash = "sha256:167e0385e062800d6c9f2d25a2a3075d7d91b67c3a02af52d772175b2313aa89", size = 35650, upload-time = "2026-03-24T14:57:08.869Z" }, ] [[package]] @@ -574,7 +561,7 @@ requires-dist = [ { name = "array-api-compat" }, { name = "cloudpickle" }, { name = "dask", extras = ["distributed"], marker = "extra == 'dask'", specifier = ">=2026.1.2" }, - { name = "earthkit-data" }, + { name = "earthkit-data", specifier = "<1" }, { name = "fire" }, { name = "networkx", marker = "extra == 'vizualize'" }, { name = "numpy" }, @@ -583,7 +570,7 @@ requires-dist = [ { name = "pyrsistent" }, { name = "pyvis", marker = "extra == 'vizualize'" }, { name = "pyzmq" }, - { name = "qubed", specifier = ">=0.3.0" }, + { name = "qubed", specifier = ">=0.3.0,<0.4.4" }, { name = "sortedcontainers" }, { name = "xarray" }, ] @@ -601,7 +588,7 @@ dev = [ [[package]] name = "eccodes" -version = "2.45.0" +version = "2.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, @@ -609,12 +596,12 @@ dependencies = [ { name = "findlibs" }, { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/c3/41378ba5547fbbb08db9efc7c520bfd0ccf2684bf7fbba0e09eab53b9eff/eccodes-2.45.0.tar.gz", hash = "sha256:08fe1544e6fa597a416bde9a630af4b6e34a021bc3c209f0ece4f7ed5990f992", size = 2466789, upload-time = "2026-01-15T16:27:08.418Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/8d/63d8287e912a99a73fa37b9c821883fb8f09a900187a8c917ba72e3743be/eccodes-2.47.0.tar.gz", hash = "sha256:ae9207ef27c67656d068aab97f172abeb85fc3620f56faa22fdac5b1be75de69", size = 2467378, upload-time = "2026-04-22T11:30:08.465Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/1b/903f014d0645811253384f2056f00e9a1c8936a4c158c83f69acf564a4ec/eccodes-2.45.0-cp311-cp311-win_amd64.whl", hash = "sha256:99162409c6ff7e1c2a4836ad246c54ffab8157b6a738e4ed856cf236ea0a8c6e", size = 7406737, upload-time = "2026-01-15T16:26:39.928Z" }, - { url = "https://files.pythonhosted.org/packages/76/2a/721b05d551f90ed198aa05c2455fd056f4f1742967cee154347eaada2745/eccodes-2.45.0-cp312-cp312-win_amd64.whl", hash = "sha256:b661ce2d0e73ce0724f137606bf6b579fb2eba4db0f5b466b4345170cb4bf5f2", size = 7406814, upload-time = "2026-01-15T16:26:42.454Z" }, - { url = "https://files.pythonhosted.org/packages/6b/33/3dceb280fbbacb10f8f1e2e1a249a7fd80ce7cc532b69d56c981df21a348/eccodes-2.45.0-cp313-cp313-win_amd64.whl", hash = "sha256:2f07d8892cca722cf67528d3f82d25c851f298cb9b9c6de4c79cb8698857a8ef", size = 7406811, upload-time = "2026-01-15T16:26:39.689Z" }, - { url = "https://files.pythonhosted.org/packages/7f/40/0a42c7441d76c373a7bef7ec1f535f26910a8c82a37e745ffcd3ea0cdf79/eccodes-2.45.0-py3-none-any.whl", hash = "sha256:0ba61dbd2844843f1fd466c8ca24107932cc40088338f6176428cf38c533c08c", size = 91433, upload-time = "2026-01-15T16:27:06.601Z" }, + { url = "https://files.pythonhosted.org/packages/09/40/42bb2d98bc6103194d25fa8da141cd43cabf32277cbf2ab513fe35532196/eccodes-2.47.0-cp311-cp311-win_amd64.whl", hash = "sha256:3b71ae31bca13e4b62712405d7eb3c988b71b0b0bbd85fe4d3eacac7fc67ae27", size = 7435928, upload-time = "2026-04-22T11:29:40.702Z" }, + { url = "https://files.pythonhosted.org/packages/31/01/d8bfb2ac2ec4831789a82cc06afc609a599f5f545dc885da1c8671a308e5/eccodes-2.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:7ab3b689fd3caa07451f7bcf07329b2268acdc7434bcedac8b13d1f20d3389c0", size = 7436009, upload-time = "2026-04-22T11:29:36.343Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ba/4cbca5157e90d90c318b2acae178d38b0c6c6df73b434e6a2b6a8cb23a2b/eccodes-2.47.0-cp313-cp313-win_amd64.whl", hash = "sha256:f5f9b71b6e2cb8c9b8349b88a09791e8fb6d51a4396d7160b3da2a83149a4973", size = 7436001, upload-time = "2026-04-22T11:29:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a3/f58ff573ba0f678ff8116686e868afe436627b19b457a2aba62cd463c9ad/eccodes-2.47.0-py3-none-any.whl", hash = "sha256:13d0b28bd58e94e2c303f42415ca0dcc56ab3febf0f52b1fb0f1d4aa5e7db8e1", size = 91567, upload-time = "2026-04-22T11:30:06.789Z" }, ] [[package]]