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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any