dynamical-catalog 0.2.0__py3-none-any.whl
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.
- dynamical_catalog/__init__.py +87 -0
- dynamical_catalog/_catalog.py +47 -0
- dynamical_catalog/_dataset_entry.py +68 -0
- dynamical_catalog/_open.py +48 -0
- dynamical_catalog/_stac.py +108 -0
- dynamical_catalog/py.typed +0 -0
- dynamical_catalog-0.2.0.dist-info/METADATA +65 -0
- dynamical_catalog-0.2.0.dist-info/RECORD +9 -0
- dynamical_catalog-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""dynamical_catalog - Load dynamical.org weather datasets in one line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
import xarray as xr
|
|
10
|
+
from zarr.abc.store import Store
|
|
11
|
+
|
|
12
|
+
from dynamical_catalog._catalog import Catalog
|
|
13
|
+
from dynamical_catalog._stac import clear_cache, load_catalog, set_identifier
|
|
14
|
+
|
|
15
|
+
__version__ = version("dynamical-catalog")
|
|
16
|
+
|
|
17
|
+
catalog = Catalog(load_catalog)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def identify(identifier: str) -> None:
|
|
21
|
+
"""Set your identity for STAC catalog requests.
|
|
22
|
+
|
|
23
|
+
The identifier (typically an email or company name) is included in
|
|
24
|
+
the User-Agent header: ``dynamical-catalog/0.1.0 (identifier)``.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
identifier: Email or company name, e.g. "marshall@dynamical.org".
|
|
28
|
+
"""
|
|
29
|
+
set_identifier(identifier)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_store(dataset_id: str, engine: str = "zarr") -> Store:
|
|
33
|
+
"""Get a zarr Store for a dynamical.org dataset.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
dataset_id: Dataset identifier (e.g. "noaa-gfs-forecast").
|
|
37
|
+
Underscores are also accepted (e.g. "noaa_gfs_forecast").
|
|
38
|
+
engine: "zarr" (default) or "icechunk".
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
zarr.abc.Store
|
|
42
|
+
"""
|
|
43
|
+
from dynamical_catalog._open import _get_store
|
|
44
|
+
|
|
45
|
+
return _get_store(_resolve(dataset_id), engine=engine)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def open(dataset_id: str, engine: str = "zarr", **kwargs: Any) -> xr.Dataset:
|
|
49
|
+
"""Open a dynamical.org dataset by ID.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
dataset_id: Dataset identifier (e.g. "noaa-gfs-forecast").
|
|
53
|
+
Underscores are also accepted (e.g. "noaa_gfs_forecast").
|
|
54
|
+
engine: "zarr" (default) or "icechunk".
|
|
55
|
+
**kwargs: Passed through to xr.open_zarr().
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
xarray.Dataset
|
|
59
|
+
"""
|
|
60
|
+
from dynamical_catalog._open import _open_dataset
|
|
61
|
+
|
|
62
|
+
return _open_dataset(_resolve(dataset_id), engine=engine, **kwargs)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def list() -> list[str]: # type: ignore[valid-type]
|
|
66
|
+
"""List available dataset IDs."""
|
|
67
|
+
return sorted(load_catalog().keys())
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _resolve(dataset_id: str) -> dict[str, Any]:
|
|
71
|
+
datasets = load_catalog()
|
|
72
|
+
normalized_id = dataset_id.replace("_", "-")
|
|
73
|
+
if normalized_id not in datasets:
|
|
74
|
+
available = ", ".join(sorted(datasets.keys()))
|
|
75
|
+
raise ValueError(f"Unknown dataset {dataset_id!r}. Available: {available}")
|
|
76
|
+
return datasets[normalized_id]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"open",
|
|
81
|
+
"get_store",
|
|
82
|
+
"list",
|
|
83
|
+
"identify",
|
|
84
|
+
"catalog",
|
|
85
|
+
"clear_cache",
|
|
86
|
+
"__version__",
|
|
87
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from dynamical_catalog._dataset_entry import DatasetEntry
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Catalog:
|
|
10
|
+
"""Provides attribute-style access to dynamical.org datasets.
|
|
11
|
+
|
|
12
|
+
Supports tab-completion in IPython/Jupyter::
|
|
13
|
+
|
|
14
|
+
>>> import dynamical_catalog
|
|
15
|
+
>>> dynamical_catalog.catalog.<TAB>
|
|
16
|
+
>>> dynamical_catalog.catalog.noaa_gfs_forecast.open()
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, loader: Callable[[], dict[str, dict[str, Any]]]) -> None:
|
|
20
|
+
self._loader = loader
|
|
21
|
+
self._entries: dict[str, DatasetEntry] = {}
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def _datasets(self) -> dict[str, dict[str, Any]]:
|
|
25
|
+
return self._loader()
|
|
26
|
+
|
|
27
|
+
def __getattr__(self, name: str) -> DatasetEntry:
|
|
28
|
+
if name.startswith("_"):
|
|
29
|
+
raise AttributeError(name)
|
|
30
|
+
dataset_id = name.replace("_", "-")
|
|
31
|
+
datasets = self._datasets
|
|
32
|
+
if dataset_id not in datasets:
|
|
33
|
+
available = ", ".join(self._dataset_attr_names())
|
|
34
|
+
raise AttributeError(f"No dataset named {name!r}. Available: {available}")
|
|
35
|
+
if dataset_id not in self._entries:
|
|
36
|
+
self._entries[dataset_id] = DatasetEntry(datasets[dataset_id])
|
|
37
|
+
return self._entries[dataset_id]
|
|
38
|
+
|
|
39
|
+
def __dir__(self) -> list[str]:
|
|
40
|
+
return list(self._dataset_attr_names()) + list(super().__dir__())
|
|
41
|
+
|
|
42
|
+
def _dataset_attr_names(self) -> list[str]:
|
|
43
|
+
return [did.replace("-", "_") for did in self._datasets]
|
|
44
|
+
|
|
45
|
+
def __repr__(self) -> str:
|
|
46
|
+
names = self._dataset_attr_names()
|
|
47
|
+
return f"Catalog({len(names)} datasets: {', '.join(names)})"
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
import xarray as xr
|
|
7
|
+
from zarr.abc.store import Store
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DatasetEntry:
|
|
11
|
+
"""A single dataset from the dynamical.org catalog."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, data: dict[str, Any]) -> None:
|
|
14
|
+
self._data = data
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def id(self) -> str:
|
|
18
|
+
return self._data["id"]
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def name(self) -> str:
|
|
22
|
+
return self._data["name"]
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def description(self) -> str:
|
|
26
|
+
return self._data["description"]
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def zarr_url(self) -> str:
|
|
30
|
+
return self._data["zarr_url"]
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def icechunk_config(self) -> dict[str, str] | None:
|
|
34
|
+
return self._data.get("icechunk")
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def status(self) -> str:
|
|
38
|
+
return self._data["status"]
|
|
39
|
+
|
|
40
|
+
def get_store(self, engine: str = "zarr") -> Store:
|
|
41
|
+
"""Get a zarr Store for this dataset.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
engine: "zarr" (default) or "icechunk".
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
zarr.abc.Store
|
|
48
|
+
"""
|
|
49
|
+
from dynamical_catalog._open import _get_store
|
|
50
|
+
|
|
51
|
+
return _get_store(self._data, engine=engine)
|
|
52
|
+
|
|
53
|
+
def open(self, engine: str = "zarr", **kwargs: Any) -> xr.Dataset:
|
|
54
|
+
"""Open this dataset as an xarray Dataset.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
engine: "zarr" (default) or "icechunk".
|
|
58
|
+
**kwargs: Passed through to xr.open_zarr().
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
xarray.Dataset
|
|
62
|
+
"""
|
|
63
|
+
from dynamical_catalog._open import _open_dataset
|
|
64
|
+
|
|
65
|
+
return _open_dataset(self._data, engine=engine, **kwargs)
|
|
66
|
+
|
|
67
|
+
def __repr__(self) -> str:
|
|
68
|
+
return f"DatasetEntry({self.id!r}, {self.name!r})"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
import icechunk
|
|
6
|
+
import xarray as xr
|
|
7
|
+
import zarr.storage
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from zarr.abc.store import Store
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_store(
|
|
14
|
+
dataset_data: dict[str, Any],
|
|
15
|
+
engine: str = "zarr",
|
|
16
|
+
) -> Store:
|
|
17
|
+
if engine == "zarr":
|
|
18
|
+
return zarr.storage.FsspecStore.from_url(dataset_data["zarr_url"])
|
|
19
|
+
if engine == "icechunk":
|
|
20
|
+
return _icechunk_store(dataset_data)
|
|
21
|
+
raise ValueError(f"Unknown engine {engine!r}. Use 'zarr' or 'icechunk'.")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _open_dataset(
|
|
25
|
+
dataset_data: dict[str, Any],
|
|
26
|
+
engine: str = "zarr",
|
|
27
|
+
**kwargs: Any,
|
|
28
|
+
) -> xr.Dataset:
|
|
29
|
+
store = _get_store(dataset_data, engine=engine)
|
|
30
|
+
return xr.open_zarr(store, **kwargs)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _icechunk_store(dataset_data: dict[str, Any]) -> Store:
|
|
34
|
+
config = dataset_data.get("icechunk")
|
|
35
|
+
if config is None:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
f"Dataset {dataset_data['id']!r} does not have icechunk configuration."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
storage = icechunk.s3_storage(
|
|
41
|
+
bucket=config["bucket"],
|
|
42
|
+
prefix=config["prefix"],
|
|
43
|
+
region=config["region"],
|
|
44
|
+
anonymous=True,
|
|
45
|
+
)
|
|
46
|
+
repo = icechunk.Repository.open(storage)
|
|
47
|
+
session = repo.readonly_session("main")
|
|
48
|
+
return session.store
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Fetch and parse the dynamical.org STAC catalog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import concurrent.futures
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.request
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urljoin
|
|
12
|
+
|
|
13
|
+
STAC_CATALOG_URL = "https://dynamical.org/stac/catalog.json"
|
|
14
|
+
|
|
15
|
+
_TIMEOUT_SECONDS = 30
|
|
16
|
+
_lock = threading.Lock()
|
|
17
|
+
_datasets: dict[str, dict[str, Any]] | None = None
|
|
18
|
+
_identifier: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def set_identifier(identifier: str) -> None:
|
|
22
|
+
global _identifier
|
|
23
|
+
_identifier = identifier
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _user_agent() -> str:
|
|
27
|
+
from dynamical_catalog import __version__
|
|
28
|
+
|
|
29
|
+
ua = f"dynamical-catalog/{__version__}"
|
|
30
|
+
if _identifier:
|
|
31
|
+
ua += f" ({_identifier})"
|
|
32
|
+
return ua
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _fetch_json(url: str) -> Any:
|
|
36
|
+
req = urllib.request.Request(url, headers={"User-Agent": _user_agent()})
|
|
37
|
+
try:
|
|
38
|
+
with urllib.request.urlopen(req, timeout=_TIMEOUT_SECONDS) as resp:
|
|
39
|
+
return json.loads(resp.read())
|
|
40
|
+
except urllib.error.URLError as e:
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
f"Failed to fetch dynamical.org STAC catalog from {url}: {e}"
|
|
43
|
+
) from e
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse_collection(collection: dict[str, Any]) -> dict[str, Any]:
|
|
47
|
+
"""Extract the dataset config we need from a STAC Collection."""
|
|
48
|
+
assets = collection.get("assets", {})
|
|
49
|
+
zarr_asset = assets.get("zarr")
|
|
50
|
+
if zarr_asset is None:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"STAC Collection {collection.get('id', '?')} is missing a 'zarr' asset"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
result: dict[str, Any] = {
|
|
56
|
+
"id": collection["id"],
|
|
57
|
+
"name": collection.get("title", collection["id"]),
|
|
58
|
+
"description": collection.get("description", ""),
|
|
59
|
+
"status": "live",
|
|
60
|
+
"zarr_url": zarr_asset["href"],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
icechunk_asset = assets.get("icechunk")
|
|
64
|
+
if icechunk_asset is not None:
|
|
65
|
+
storage = icechunk_asset.get("icechunk:storage", {})
|
|
66
|
+
result["icechunk"] = {
|
|
67
|
+
"bucket": storage["bucket"],
|
|
68
|
+
"prefix": storage["prefix"],
|
|
69
|
+
"region": storage["region"],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_catalog() -> dict[str, dict[str, Any]]:
|
|
76
|
+
"""Fetch the STAC catalog and all child collections.
|
|
77
|
+
|
|
78
|
+
Results are cached in-process after the first call.
|
|
79
|
+
Child collections are fetched in parallel for faster startup.
|
|
80
|
+
"""
|
|
81
|
+
global _datasets
|
|
82
|
+
if _datasets is not None:
|
|
83
|
+
return _datasets
|
|
84
|
+
|
|
85
|
+
with _lock:
|
|
86
|
+
if _datasets is not None:
|
|
87
|
+
return _datasets
|
|
88
|
+
|
|
89
|
+
catalog = _fetch_json(STAC_CATALOG_URL)
|
|
90
|
+
child_links = [link for link in catalog["links"] if link["rel"] == "child"]
|
|
91
|
+
urls = [urljoin(STAC_CATALOG_URL, link["href"]) for link in child_links]
|
|
92
|
+
|
|
93
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
94
|
+
collections = pool.map(_fetch_json, urls)
|
|
95
|
+
|
|
96
|
+
datasets: dict[str, dict[str, Any]] = {}
|
|
97
|
+
for collection in collections:
|
|
98
|
+
parsed = _parse_collection(collection)
|
|
99
|
+
datasets[parsed["id"]] = parsed
|
|
100
|
+
|
|
101
|
+
_datasets = datasets
|
|
102
|
+
return _datasets
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def clear_cache() -> None:
|
|
106
|
+
"""Clear the cached catalog data, forcing a fresh fetch on next access."""
|
|
107
|
+
global _datasets
|
|
108
|
+
_datasets = None
|
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dynamical-catalog
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Load dynamical.org weather datasets in one line
|
|
5
|
+
Project-URL: Homepage, https://dynamical.org
|
|
6
|
+
Project-URL: Repository, https://github.com/dynamical-org/dynamical-catalog
|
|
7
|
+
Project-URL: Catalog, https://dynamical.org/catalog/
|
|
8
|
+
Project-URL: Issues, https://github.com/dynamical-org/dynamical-catalog/issues
|
|
9
|
+
Author-email: "dynamical.org" <feedback@dynamical.org>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Atmospheric Science
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: aiohttp
|
|
22
|
+
Requires-Dist: fsspec
|
|
23
|
+
Requires-Dist: icechunk>=1.0.0
|
|
24
|
+
Requires-Dist: xarray>=2025.1.2
|
|
25
|
+
Requires-Dist: zarr>=3.0.8
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-mock; extra == 'dev'
|
|
30
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# dynamical-catalog
|
|
34
|
+
|
|
35
|
+
Load [dynamical.org](https://dynamical.org) weather datasets in one line.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install dynamical-catalog
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import dynamical_catalog
|
|
47
|
+
|
|
48
|
+
# Optional: let us know who you are so we can improve the catalog!
|
|
49
|
+
dynamical_catalog.identify("you@example.com")
|
|
50
|
+
|
|
51
|
+
# Open a dataset as xarray (zarr v3)
|
|
52
|
+
ds = dynamical_catalog.open("noaa-gfs-forecast")
|
|
53
|
+
|
|
54
|
+
# Open via icechunk
|
|
55
|
+
ds = dynamical_catalog.open("noaa-gfs-forecast", engine="icechunk")
|
|
56
|
+
|
|
57
|
+
# Get the underlying zarr store
|
|
58
|
+
store = dynamical_catalog.get_store("noaa-gfs-forecast")
|
|
59
|
+
|
|
60
|
+
# Tab-completable catalog
|
|
61
|
+
ds = dynamical_catalog.catalog.noaa_gfs_forecast.open()
|
|
62
|
+
|
|
63
|
+
# List all available datasets
|
|
64
|
+
dynamical_catalog.list()
|
|
65
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
dynamical_catalog/__init__.py,sha256=jB7lotnHdS9Uq1kM12Oqmz823V_3WyOcd5LG-7DAE0Y,2445
|
|
2
|
+
dynamical_catalog/_catalog.py,sha256=aUv0F-VW776YsYVFxe51R95YgGey3x6aSVP7pRDiziA,1626
|
|
3
|
+
dynamical_catalog/_dataset_entry.py,sha256=fGA-V38GUsfX_d405wjZ9uEGQKCsqoV4Q8CL9nOIX7o,1686
|
|
4
|
+
dynamical_catalog/_open.py,sha256=iUMyxHqfkboFy1yR_eXbUZYkeM_Ibp98D7VNs3ACtAU,1265
|
|
5
|
+
dynamical_catalog/_stac.py,sha256=F7E_mSHGxe8AQcROs3aCN7t7xY0zIqO3NHtVo-7MbCo,3179
|
|
6
|
+
dynamical_catalog/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
dynamical_catalog-0.2.0.dist-info/METADATA,sha256=D8R8vTYSvV-zgYuG9mc7V7HVxl_2CDwJsXGydL08_A4,2006
|
|
8
|
+
dynamical_catalog-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
dynamical_catalog-0.2.0.dist-info/RECORD,,
|