eo-fetch-api 1.0.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.
- eo_fetch/__init__.py +48 -0
- eo_fetch/__main__.py +3 -0
- eo_fetch/api.py +288 -0
- eo_fetch/backends/__init__.py +71 -0
- eo_fetch/backends/base.py +28 -0
- eo_fetch/backends/cds.py +174 -0
- eo_fetch/backends/credentials.py +31 -0
- eo_fetch/backends/ecmwf.py +135 -0
- eo_fetch/backends/esgf.py +231 -0
- eo_fetch/backends/eumetsat.py +172 -0
- eo_fetch/backends/http_index.py +161 -0
- eo_fetch/backends/local.py +45 -0
- eo_fetch/backends/marine.py +158 -0
- eo_fetch/backends/nasa.py +240 -0
- eo_fetch/backends/s3.py +136 -0
- eo_fetch/backends/stac.py +237 -0
- eo_fetch/bridge.py +53 -0
- eo_fetch/catalog/__init__.py +5 -0
- eo_fetch/catalog/copernicus_ecmwf.py +130 -0
- eo_fetch/catalog/descriptions.py +116 -0
- eo_fetch/catalog/dwd.py +50 -0
- eo_fetch/catalog/eumetsat.py +42 -0
- eo_fetch/catalog/nasa.py +37 -0
- eo_fetch/catalog/noaa.py +67 -0
- eo_fetch/catalog/stac.py +88 -0
- eo_fetch/checkreaders.py +142 -0
- eo_fetch/cli.py +469 -0
- eo_fetch/discovery.py +111 -0
- eo_fetch/downloader.py +112 -0
- eo_fetch/manifest.py +73 -0
- eo_fetch/models.py +56 -0
- eo_fetch/plotting.py +178 -0
- eo_fetch/readers/__init__.py +188 -0
- eo_fetch/readers/generic.py +83 -0
- eo_fetch/readers/radolan.py +112 -0
- eo_fetch/readers/satellite.py +170 -0
- eo_fetch/readers/unpack.py +81 -0
- eo_fetch/registry.py +70 -0
- eo_fetch/regrid.py +125 -0
- eo_fetch/styles.py +223 -0
- eo_fetch/timeguess.py +63 -0
- eo_fetch_api-1.0.0.dist-info/METADATA +230 -0
- eo_fetch_api-1.0.0.dist-info/RECORD +47 -0
- eo_fetch_api-1.0.0.dist-info/WHEEL +5 -0
- eo_fetch_api-1.0.0.dist-info/entry_points.txt +2 -0
- eo_fetch_api-1.0.0.dist-info/licenses/LICENSE +21 -0
- eo_fetch_api-1.0.0.dist-info/top_level.txt +1 -0
eo_fetch/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""eo-fetch: one search-and-download interface for Earth-observation and climate data.
|
|
2
|
+
|
|
3
|
+
import eo_fetch as eof
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "1.0.0"
|
|
7
|
+
|
|
8
|
+
from eo_fetch.api import (
|
|
9
|
+
Results,
|
|
10
|
+
collections,
|
|
11
|
+
convert,
|
|
12
|
+
doctor,
|
|
13
|
+
download,
|
|
14
|
+
login,
|
|
15
|
+
plot,
|
|
16
|
+
products,
|
|
17
|
+
provider,
|
|
18
|
+
providers,
|
|
19
|
+
search,
|
|
20
|
+
to_earthkit,
|
|
21
|
+
verify,
|
|
22
|
+
)
|
|
23
|
+
from eo_fetch.api import open_data as open
|
|
24
|
+
from eo_fetch.discovery import ProductMatch
|
|
25
|
+
from eo_fetch.downloader import Result
|
|
26
|
+
from eo_fetch.models import Item, Query
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Item",
|
|
30
|
+
"ProductMatch",
|
|
31
|
+
"Query",
|
|
32
|
+
"Result",
|
|
33
|
+
"Results",
|
|
34
|
+
"__version__",
|
|
35
|
+
"collections",
|
|
36
|
+
"convert",
|
|
37
|
+
"doctor",
|
|
38
|
+
"download",
|
|
39
|
+
"login",
|
|
40
|
+
"open",
|
|
41
|
+
"plot",
|
|
42
|
+
"products",
|
|
43
|
+
"provider",
|
|
44
|
+
"providers",
|
|
45
|
+
"search",
|
|
46
|
+
"to_earthkit",
|
|
47
|
+
"verify",
|
|
48
|
+
]
|
eo_fetch/__main__.py
ADDED
eo_fetch/api.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""The Python API: ``import eo_fetch as eof``.
|
|
2
|
+
|
|
3
|
+
import eo_fetch as eof
|
|
4
|
+
|
|
5
|
+
found = eof.search("dwd", "radolan-yw", limit=2)
|
|
6
|
+
eof.download(found, "data/dwd")
|
|
7
|
+
ds = eof.open("data/dwd/raa01-yw_10000-2611032225-dwd---bin.hdf5")
|
|
8
|
+
|
|
9
|
+
Everything the command line does is available here, with the same names and rules.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
15
|
+
from datetime import UTC, date, datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from eo_fetch.backends import backend_factory, get_backend, param_names
|
|
19
|
+
from eo_fetch.discovery import ProductMatch, find_products
|
|
20
|
+
from eo_fetch.downloader import Result, download_items
|
|
21
|
+
from eo_fetch.manifest import build_manifest, verify_manifest, write_manifest
|
|
22
|
+
from eo_fetch.models import Item, Query
|
|
23
|
+
from eo_fetch.registry import ProviderInfo, all_providers, get_provider
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_time(value: str | date | datetime) -> datetime:
|
|
27
|
+
"""An ISO string, date or datetime as a timezone-aware datetime (naive values are UTC)."""
|
|
28
|
+
if isinstance(value, datetime):
|
|
29
|
+
moment = value
|
|
30
|
+
elif isinstance(value, date):
|
|
31
|
+
moment = datetime(value.year, value.month, value.day)
|
|
32
|
+
else:
|
|
33
|
+
moment = datetime.fromisoformat(str(value))
|
|
34
|
+
return moment if moment.tzinfo else moment.replace(tzinfo=UTC)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def param_text(value: object) -> str:
|
|
38
|
+
"""A search parameter as text; lists and tuples become comma-separated values."""
|
|
39
|
+
if isinstance(value, list | tuple | set | frozenset):
|
|
40
|
+
return ",".join(str(part) for part in value)
|
|
41
|
+
return str(value)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def make_backend(info: ProviderInfo, user_params: Mapping[str, str]):
|
|
45
|
+
"""Create the provider's backend; unknown parameter names raise ValueError."""
|
|
46
|
+
constructor, search_names = param_names(info.backend)
|
|
47
|
+
unknown = set(user_params) - constructor - search_names
|
|
48
|
+
if getattr(backend_factory(info.backend), "open_params", False):
|
|
49
|
+
unknown = set() # every other parameter is a search facet
|
|
50
|
+
if unknown:
|
|
51
|
+
accepted = ", ".join(sorted(constructor | search_names)) or "none"
|
|
52
|
+
raise ValueError(
|
|
53
|
+
f"Unknown --param {', '.join(sorted(unknown))} for provider {info.name} "
|
|
54
|
+
f"(accepted: {accepted})"
|
|
55
|
+
)
|
|
56
|
+
return get_backend(info.backend, {**info.backend_params, **user_params})
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Results(list[Item]):
|
|
60
|
+
"""The items a search found, plus what is needed to download them.
|
|
61
|
+
|
|
62
|
+
It is a normal list of :class:`Item`. Slices and :meth:`where` keep the download context, so
|
|
63
|
+
``eof.download(found[:3], dest)`` and ``eof.download(found.where(...), dest)`` work.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, items: Iterable[Item], *, info: ProviderInfo, backend, query: Query):
|
|
67
|
+
super().__init__(items)
|
|
68
|
+
self.info = info
|
|
69
|
+
self.backend = backend
|
|
70
|
+
self.query = query
|
|
71
|
+
|
|
72
|
+
def __getitem__(self, index):
|
|
73
|
+
picked = super().__getitem__(index)
|
|
74
|
+
return self.derive(picked) if isinstance(index, slice) else picked
|
|
75
|
+
|
|
76
|
+
def derive(self, items: Iterable[Item]) -> Results:
|
|
77
|
+
return Results(items, info=self.info, backend=self.backend, query=self.query)
|
|
78
|
+
|
|
79
|
+
def where(self, keep: Callable[[Item], bool]) -> Results:
|
|
80
|
+
"""The items for which ``keep(item)`` is true."""
|
|
81
|
+
return self.derive(item for item in self if keep(item))
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def megabytes(self) -> float:
|
|
85
|
+
"""Total size in megabytes (unknown sizes count as 0)."""
|
|
86
|
+
return sum(item.expected_size or 0 for item in self) / 1e6
|
|
87
|
+
|
|
88
|
+
def download(self, dest, **options) -> list[Result]:
|
|
89
|
+
"""Same as ``eof.download(self, dest, **options)``."""
|
|
90
|
+
return download(self, dest, **options)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def providers() -> list[ProviderInfo]:
|
|
94
|
+
"""All provider cards (name, access type, licence, last verified, notes)."""
|
|
95
|
+
return all_providers()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def provider(name: str) -> ProviderInfo:
|
|
99
|
+
"""The card of one provider."""
|
|
100
|
+
return get_provider(name)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def products(
|
|
104
|
+
text: str = "",
|
|
105
|
+
provider: str | None = None,
|
|
106
|
+
*,
|
|
107
|
+
live: bool | None = None,
|
|
108
|
+
browse: bool | None = None,
|
|
109
|
+
on_error: Callable[[ProviderInfo, Exception], None] | None = None,
|
|
110
|
+
) -> list[ProductMatch]:
|
|
111
|
+
"""Find the product names to use in :func:`search`.
|
|
112
|
+
|
|
113
|
+
``products()`` lists every alias of every provider; ``products("precipitation")`` keeps those
|
|
114
|
+
whose name or description contains the word(s); ``products(provider="dwd")`` lists one
|
|
115
|
+
provider (and, for file-tree providers, its top-level folders). With a ``provider`` and a
|
|
116
|
+
``text`` the live catalogue of the provider is searched too (``live=True`` does so for all
|
|
117
|
+
providers, which needs the internet and, for some, credentials). Each result's ``product``
|
|
118
|
+
is what you pass to :func:`search`. A provider whose live catalogue cannot be reached is
|
|
119
|
+
skipped with a warning (or ``on_error(info, exception)`` if given).
|
|
120
|
+
"""
|
|
121
|
+
return find_products(
|
|
122
|
+
text, provider, live=live, browse=browse, make_backend=make_backend, on_error=on_error
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def search(
|
|
127
|
+
provider: str,
|
|
128
|
+
product: str = "",
|
|
129
|
+
*,
|
|
130
|
+
start=None,
|
|
131
|
+
end=None,
|
|
132
|
+
bbox=None,
|
|
133
|
+
text: str = "",
|
|
134
|
+
limit: int | None = None,
|
|
135
|
+
params: Mapping[str, object] | None = None,
|
|
136
|
+
**extra: object,
|
|
137
|
+
) -> Results:
|
|
138
|
+
"""Find items. ``start``/``end`` take ISO strings, dates or datetimes (``end`` is exclusive).
|
|
139
|
+
|
|
140
|
+
``bbox`` is (west, south, east, north) in degrees. Provider-specific options are keyword
|
|
141
|
+
arguments (or a ``params`` dict), for example ``assets="B04,B08"`` or
|
|
142
|
+
``variable_id=["tas", "pr"]``; they are the ``--param`` options of the command line.
|
|
143
|
+
"""
|
|
144
|
+
info = get_provider(provider)
|
|
145
|
+
user_params = {
|
|
146
|
+
key: param_text(value)
|
|
147
|
+
for key, value in {**(params or {}), **extra}.items()
|
|
148
|
+
if value is not None
|
|
149
|
+
}
|
|
150
|
+
backend = make_backend(info, user_params)
|
|
151
|
+
query = Query(
|
|
152
|
+
provider=info.name,
|
|
153
|
+
product=info.products.get(product, product),
|
|
154
|
+
start=parse_time(start) if start is not None else None,
|
|
155
|
+
end=parse_time(end) if end is not None else None,
|
|
156
|
+
bbox=tuple(float(value) for value in bbox) if bbox else None,
|
|
157
|
+
text=text,
|
|
158
|
+
limit=limit,
|
|
159
|
+
params=user_params,
|
|
160
|
+
)
|
|
161
|
+
return Results(backend.search(query), info=info, backend=backend, query=query)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def download(
|
|
165
|
+
results: Results,
|
|
166
|
+
dest,
|
|
167
|
+
*,
|
|
168
|
+
workers: int | None = None,
|
|
169
|
+
retries: int = 3,
|
|
170
|
+
manifest=None,
|
|
171
|
+
on_result: Callable[[Result], None] | None = None,
|
|
172
|
+
) -> list[Result]:
|
|
173
|
+
"""Download the items of a search into ``dest`` and write the manifest.
|
|
174
|
+
|
|
175
|
+
Files are verified (size and checksum when the provider announces them), retried on errors,
|
|
176
|
+
and never exceed the connections the service allows. The manifest goes to
|
|
177
|
+
``dest/manifest.json`` unless ``manifest`` is given. Failures do not raise: check
|
|
178
|
+
``result.status`` (``downloaded``, ``skipped`` or ``failed``) and ``result.error``.
|
|
179
|
+
"""
|
|
180
|
+
if not isinstance(results, Results):
|
|
181
|
+
raise TypeError(
|
|
182
|
+
"Pass the result of eof.search() (or a slice or .where() of it), not a plain list"
|
|
183
|
+
)
|
|
184
|
+
if not results:
|
|
185
|
+
return []
|
|
186
|
+
dest = Path(dest)
|
|
187
|
+
outcome = download_items(
|
|
188
|
+
results.backend, list(results), dest, workers=workers, retries=retries, on_result=on_result
|
|
189
|
+
)
|
|
190
|
+
path = Path(manifest) if manifest else dest / "manifest.json"
|
|
191
|
+
write_manifest(path, build_manifest(results.query, results.info, outcome))
|
|
192
|
+
return outcome
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def verify(manifest, root=None) -> list[str]:
|
|
196
|
+
"""Check downloaded files against a manifest; returns the problems (empty when all is well)."""
|
|
197
|
+
manifest = Path(manifest)
|
|
198
|
+
return verify_manifest(manifest, Path(root) if root else manifest.parent)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def collections(provider: str, text: str = "", **params: object) -> list[tuple[str, str]]:
|
|
202
|
+
"""List the collections or datasets a provider offers, optionally filtered by ``text``."""
|
|
203
|
+
info = get_provider(provider)
|
|
204
|
+
backend = make_backend(info, {k: param_text(v) for k, v in params.items()})
|
|
205
|
+
lister = getattr(backend, "collections", None)
|
|
206
|
+
if lister is None:
|
|
207
|
+
raise NotImplementedError(f"The {info.backend} backend has no collection listing")
|
|
208
|
+
return lister(text)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def login(provider: str) -> None:
|
|
212
|
+
"""Ask for the provider's credentials (hidden prompts) and save them in your user folder."""
|
|
213
|
+
info = get_provider(provider)
|
|
214
|
+
backend = make_backend(info, {})
|
|
215
|
+
login_step = getattr(backend, "login", None)
|
|
216
|
+
if login_step is None:
|
|
217
|
+
raise NotImplementedError(f"{info.name} has no login step; see doctor({info.name!r})")
|
|
218
|
+
login_step()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def doctor(provider: str, **params: object) -> bool:
|
|
222
|
+
"""Print what is needed to access a provider and whether it works; True when all is well."""
|
|
223
|
+
info = get_provider(provider)
|
|
224
|
+
backend = make_backend(info, {k: param_text(v) for k, v in params.items()})
|
|
225
|
+
print(f"{info.name}: {info.title}")
|
|
226
|
+
print(f" access: {info.auth}; latency: {info.latency or 'not stated'}")
|
|
227
|
+
print(f" licence: {info.licence or 'not stated'}")
|
|
228
|
+
check = getattr(backend, "doctor", None)
|
|
229
|
+
if check is None:
|
|
230
|
+
print(" no access diagnostics implemented for this backend yet")
|
|
231
|
+
return True
|
|
232
|
+
return bool(check())
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def open_data(path, format: str | None = None, **options):
|
|
236
|
+
"""Open a downloaded file as an ``xarray.Dataset`` (NetCDF, GRIB, GeoTIFF, SEVIRI, ...)."""
|
|
237
|
+
from eo_fetch.readers import open_data as reader
|
|
238
|
+
|
|
239
|
+
return reader(path, format=format, **options)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def plot(
|
|
243
|
+
source,
|
|
244
|
+
output=None,
|
|
245
|
+
*,
|
|
246
|
+
variable: str | None = None,
|
|
247
|
+
style: str | None = None,
|
|
248
|
+
domain=None,
|
|
249
|
+
resolution: float | None = None,
|
|
250
|
+
bbox=None,
|
|
251
|
+
title: str | None = None,
|
|
252
|
+
**options,
|
|
253
|
+
) -> Path:
|
|
254
|
+
"""Draw a file or Dataset on a map with the shared styles; returns the image path."""
|
|
255
|
+
from eo_fetch import plotting
|
|
256
|
+
|
|
257
|
+
if output is None:
|
|
258
|
+
if not isinstance(source, str | Path):
|
|
259
|
+
raise ValueError("Give output=... when plotting a Dataset")
|
|
260
|
+
output = Path("plots") / (Path(source).stem[:60] + ".png")
|
|
261
|
+
plotting.plot(
|
|
262
|
+
source,
|
|
263
|
+
Path(output),
|
|
264
|
+
variable=variable,
|
|
265
|
+
style=style,
|
|
266
|
+
domain=domain,
|
|
267
|
+
resolution=resolution,
|
|
268
|
+
bbox=tuple(bbox) if bbox else None,
|
|
269
|
+
title=title,
|
|
270
|
+
**options,
|
|
271
|
+
)
|
|
272
|
+
return Path(output)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def convert(source, output, *, resolution: float | None = None, bbox=None, **options) -> Path:
|
|
276
|
+
"""Write a readable file as NetCDF (``.nc``) or Zarr (``.zarr``), optionally on lat/lon."""
|
|
277
|
+
from eo_fetch import bridge
|
|
278
|
+
|
|
279
|
+
return bridge.convert(
|
|
280
|
+
source, Path(output), resolution=resolution, bbox=tuple(bbox) if bbox else None, **options
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def to_earthkit(source, variable: str | None = None, resolution=None, bbox=None, **options):
|
|
285
|
+
"""An earthkit-data object for a file, ready for earthkit-plots, -regrid or -transforms."""
|
|
286
|
+
from eo_fetch import bridge
|
|
287
|
+
|
|
288
|
+
return bridge.to_earthkit(source, variable, resolution, bbox, **options)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Backends and the registry that builds them by name."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
|
|
8
|
+
from eo_fetch.backends.base import Backend
|
|
9
|
+
from eo_fetch.backends.cds import CdsBackend
|
|
10
|
+
from eo_fetch.backends.ecmwf import EcmwfOpenDataBackend
|
|
11
|
+
from eo_fetch.backends.esgf import EsgfBackend
|
|
12
|
+
from eo_fetch.backends.eumetsat import EumetsatBackend
|
|
13
|
+
from eo_fetch.backends.http_index import HttpIndexBackend
|
|
14
|
+
from eo_fetch.backends.local import LocalBackend
|
|
15
|
+
from eo_fetch.backends.marine import MarineBackend
|
|
16
|
+
from eo_fetch.backends.nasa import NasaBackend
|
|
17
|
+
from eo_fetch.backends.s3 import S3Backend
|
|
18
|
+
from eo_fetch.backends.stac import StacBackend
|
|
19
|
+
|
|
20
|
+
# Backends import their third-party library only when first used, so listing is always cheap.
|
|
21
|
+
BACKENDS: dict[str, Callable[..., Backend]] = {
|
|
22
|
+
"local": LocalBackend,
|
|
23
|
+
"eumetsat": EumetsatBackend,
|
|
24
|
+
"s3": S3Backend,
|
|
25
|
+
"http-index": HttpIndexBackend,
|
|
26
|
+
"nasa": NasaBackend,
|
|
27
|
+
"stac": StacBackend,
|
|
28
|
+
"ecmwf-opendata": EcmwfOpenDataBackend,
|
|
29
|
+
"cds": CdsBackend,
|
|
30
|
+
"marine": MarineBackend,
|
|
31
|
+
"esgf": EsgfBackend,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def backend_factory(name: str) -> Callable[..., Backend]:
|
|
36
|
+
try:
|
|
37
|
+
return BACKENDS[name]
|
|
38
|
+
except KeyError:
|
|
39
|
+
known = ", ".join(sorted(BACKENDS))
|
|
40
|
+
raise KeyError(f"Unknown backend {name!r}. Available: {known}") from None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def param_names(name: str) -> tuple[set[str], set[str]]:
|
|
44
|
+
"""Return (constructor parameters, search parameters) a backend accepts."""
|
|
45
|
+
factory = backend_factory(name)
|
|
46
|
+
return set(inspect.signature(factory).parameters), set(getattr(factory, "query_params", ()))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_backend(name: str, params: Mapping[str, str] | None = None) -> Backend:
|
|
50
|
+
"""Build a backend by name; only the parameters its constructor accepts are passed on."""
|
|
51
|
+
accepted, _ = param_names(name)
|
|
52
|
+
return backend_factory(name)(**{k: v for k, v in (params or {}).items() if k in accepted})
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"BACKENDS",
|
|
57
|
+
"Backend",
|
|
58
|
+
"CdsBackend",
|
|
59
|
+
"EcmwfOpenDataBackend",
|
|
60
|
+
"EsgfBackend",
|
|
61
|
+
"EumetsatBackend",
|
|
62
|
+
"HttpIndexBackend",
|
|
63
|
+
"LocalBackend",
|
|
64
|
+
"MarineBackend",
|
|
65
|
+
"NasaBackend",
|
|
66
|
+
"S3Backend",
|
|
67
|
+
"StacBackend",
|
|
68
|
+
"backend_factory",
|
|
69
|
+
"get_backend",
|
|
70
|
+
"param_names",
|
|
71
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The interface every backend implements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from eo_fetch.models import Item, Query
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Backend(Protocol):
|
|
12
|
+
"""A wrapper around one access method (a library or a protocol).
|
|
13
|
+
|
|
14
|
+
``max_connections`` is the number of parallel downloads the service tolerates. ``fetch``
|
|
15
|
+
must write the complete object to ``target``; the downloader passes a temporary name and
|
|
16
|
+
renames it after verification.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
max_connections: int
|
|
21
|
+
|
|
22
|
+
def search(self, query: Query) -> list[Item]:
|
|
23
|
+
"""Return the items matching the query, sorted by time then key."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
def fetch(self, item: Item, target: Path) -> None:
|
|
27
|
+
"""Write the item to ``target``."""
|
|
28
|
+
...
|
eo_fetch/backends/cds.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Copernicus Climate Data Store backend (ERA5 and other hourly datasets), via cdsapi.
|
|
2
|
+
|
|
3
|
+
``search`` plans one request per month of the time range; ``fetch`` submits it to the CDS queue
|
|
4
|
+
and downloads the result. Requests can wait in the queue for minutes or hours.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from calendar import monthrange
|
|
13
|
+
from datetime import UTC, datetime, timedelta
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from eo_fetch.models import Item, Query
|
|
17
|
+
|
|
18
|
+
DEFAULT_HOURS = ("00:00", "06:00", "12:00", "18:00")
|
|
19
|
+
CDS_URL = "https://cds.climate.copernicus.eu/api"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def split_list(text: str) -> list[str]:
|
|
23
|
+
return [part.strip() for part in text.split(",") if part.strip()]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def month_requests(start: datetime, end: datetime) -> list[tuple[int, int, list[int]]]:
|
|
27
|
+
"""(year, month, days) for every month touched by [start, end); only the days inside."""
|
|
28
|
+
result = []
|
|
29
|
+
day = datetime(start.year, start.month, start.day, tzinfo=UTC)
|
|
30
|
+
while day < end:
|
|
31
|
+
last = monthrange(day.year, day.month)[1]
|
|
32
|
+
days = []
|
|
33
|
+
cursor = day
|
|
34
|
+
while cursor < end and cursor.month == day.month:
|
|
35
|
+
days.append(cursor.day)
|
|
36
|
+
cursor += timedelta(days=1)
|
|
37
|
+
result.append((day.year, day.month, days))
|
|
38
|
+
day = datetime(day.year, day.month, last, tzinfo=UTC) + timedelta(days=1)
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_request(
|
|
43
|
+
year: int,
|
|
44
|
+
month: int,
|
|
45
|
+
days: list[int],
|
|
46
|
+
variables: list[str],
|
|
47
|
+
hours: list[str],
|
|
48
|
+
bbox,
|
|
49
|
+
data_format: str,
|
|
50
|
+
product_type: str,
|
|
51
|
+
levels: list[str],
|
|
52
|
+
extra: dict,
|
|
53
|
+
) -> dict:
|
|
54
|
+
"""The CDS request for one month (ERA5 style; ``extra`` can add or replace keys)."""
|
|
55
|
+
request: dict = {
|
|
56
|
+
"variable": variables,
|
|
57
|
+
"year": f"{year:04d}",
|
|
58
|
+
"month": f"{month:02d}",
|
|
59
|
+
"day": [f"{d:02d}" for d in days],
|
|
60
|
+
"time": hours,
|
|
61
|
+
"data_format": data_format,
|
|
62
|
+
"download_format": "unarchived",
|
|
63
|
+
}
|
|
64
|
+
if product_type:
|
|
65
|
+
request["product_type"] = [product_type]
|
|
66
|
+
if levels:
|
|
67
|
+
request["pressure_level"] = levels
|
|
68
|
+
if bbox:
|
|
69
|
+
west, south, east, north = bbox
|
|
70
|
+
request["area"] = [north, west, south, east] # the CDS wants N, W, S, E
|
|
71
|
+
request.update(extra)
|
|
72
|
+
return request
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class CdsBackend:
|
|
76
|
+
"""Plan and fetch hourly datasets of the Copernicus Climate Data Store (ERA5 and others).
|
|
77
|
+
|
|
78
|
+
``query.product`` is the dataset name (``reanalysis-era5-single-levels``). Search needs
|
|
79
|
+
``--start`` and ``--end`` (end exclusive). Parameters: ``variable`` (required, CDS names
|
|
80
|
+
such as ``2m_temperature,total_precipitation``), ``hours`` (default 00:00,06:00,12:00,18:00),
|
|
81
|
+
``format`` (``grib`` or ``netcdf``; default grib), ``product_type`` (default reanalysis),
|
|
82
|
+
``level`` (pressure levels in hPa), and ``extra`` (a JSON object merged into the request).
|
|
83
|
+
``--bbox`` becomes the request's area. Credentials are read by cdsapi from ``~/.cdsapirc``
|
|
84
|
+
(or CDSAPI_URL and CDSAPI_KEY) and never stored by eo-fetch. Accept each dataset's licence
|
|
85
|
+
once on the dataset's page of cds.climate.copernicus.eu.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
name = "cds"
|
|
89
|
+
max_connections = 2
|
|
90
|
+
query_params = frozenset({"variable", "hours", "format", "product_type", "level", "extra"})
|
|
91
|
+
|
|
92
|
+
def __init__(self) -> None:
|
|
93
|
+
self._client = None
|
|
94
|
+
|
|
95
|
+
def login(self) -> None:
|
|
96
|
+
"""Ask for the CDS personal access token (hidden) and save it to ~/.cdsapirc."""
|
|
97
|
+
from eo_fetch.backends.credentials import ask_secret, confirm_overwrite, write_private
|
|
98
|
+
|
|
99
|
+
path = Path.home() / ".cdsapirc"
|
|
100
|
+
if not confirm_overwrite(path):
|
|
101
|
+
print("Left unchanged.")
|
|
102
|
+
return
|
|
103
|
+
print("Find your token on your profile page at https://cds.climate.copernicus.eu")
|
|
104
|
+
key = ask_secret("CDS personal access token (input hidden): ")
|
|
105
|
+
write_private(path, f"url: {CDS_URL}\nkey: {key}\n")
|
|
106
|
+
print(f"Saved to {path} (plain text, in your user folder). Delete the file to log out.")
|
|
107
|
+
print("Remember to accept each dataset's licence on its page before downloading.")
|
|
108
|
+
|
|
109
|
+
def client(self):
|
|
110
|
+
if self._client is None:
|
|
111
|
+
import cdsapi
|
|
112
|
+
|
|
113
|
+
self._client = cdsapi.Client()
|
|
114
|
+
return self._client
|
|
115
|
+
|
|
116
|
+
def search(self, query: Query) -> list[Item]:
|
|
117
|
+
if not query.product:
|
|
118
|
+
raise ValueError("CDS search needs --product with a dataset name")
|
|
119
|
+
if not (query.start and query.end):
|
|
120
|
+
raise ValueError("CDS search needs both --start and --end (end is exclusive)")
|
|
121
|
+
variables = split_list(query.params.get("variable") or "")
|
|
122
|
+
if not variables:
|
|
123
|
+
raise ValueError("Give the variables with --param variable=2m_temperature,...")
|
|
124
|
+
hours = split_list(query.params.get("hours") or "") or list(DEFAULT_HOURS)
|
|
125
|
+
data_format = query.params.get("format") or "grib"
|
|
126
|
+
if data_format not in {"grib", "netcdf"}:
|
|
127
|
+
raise ValueError("--param format must be grib or netcdf")
|
|
128
|
+
extra = json.loads(query.params.get("extra") or "{}")
|
|
129
|
+
product_type = query.params.get("product_type", "reanalysis")
|
|
130
|
+
levels = split_list(query.params.get("level") or "")
|
|
131
|
+
suffix = "grib" if data_format == "grib" else "nc"
|
|
132
|
+
label = re.sub(r"[^A-Za-z0-9]+", "-", "-".join(variables))[:60].strip("-")
|
|
133
|
+
items = []
|
|
134
|
+
for year, month, days in month_requests(query.start, query.end):
|
|
135
|
+
request = build_request(
|
|
136
|
+
year=year,
|
|
137
|
+
month=month,
|
|
138
|
+
days=days,
|
|
139
|
+
variables=variables,
|
|
140
|
+
hours=hours,
|
|
141
|
+
bbox=query.bbox,
|
|
142
|
+
data_format=data_format,
|
|
143
|
+
product_type=product_type,
|
|
144
|
+
levels=levels,
|
|
145
|
+
extra=extra,
|
|
146
|
+
)
|
|
147
|
+
items.append(
|
|
148
|
+
Item(
|
|
149
|
+
provider=query.provider,
|
|
150
|
+
product=query.product,
|
|
151
|
+
key=f"{query.product}_{year:04d}{month:02d}_{label}.{suffix}",
|
|
152
|
+
time=datetime(year, month, days[0], tzinfo=UTC),
|
|
153
|
+
meta={"request": json.dumps(request)},
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
return items[: query.limit] if query.limit else items
|
|
157
|
+
|
|
158
|
+
def fetch(self, item: Item, target: Path) -> None:
|
|
159
|
+
request = json.loads(item.meta["request"])
|
|
160
|
+
self.client().retrieve(item.product, request, str(target))
|
|
161
|
+
|
|
162
|
+
def doctor(self, collection_ids: tuple[str, ...] = ()) -> bool:
|
|
163
|
+
rc = Path.home() / ".cdsapirc"
|
|
164
|
+
if os.environ.get("CDSAPI_KEY") or rc.is_file():
|
|
165
|
+
print(" credentials: found (CDSAPI_KEY or ~/.cdsapirc), tested when a request runs")
|
|
166
|
+
else:
|
|
167
|
+
print(
|
|
168
|
+
" credentials: NOT FOUND. Create a free account at "
|
|
169
|
+
"https://cds.climate.copernicus.eu, then run `eo-fetch login --provider cds` "
|
|
170
|
+
"(or write ~/.cdsapirc yourself, or set CDSAPI_URL and CDSAPI_KEY)."
|
|
171
|
+
)
|
|
172
|
+
return False
|
|
173
|
+
print(" licences: accept each dataset's licence on its page, or requests will be refused")
|
|
174
|
+
return True
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Helpers for the ``eo-fetch login`` commands: hidden prompts and private credential files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import getpass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ask_secret(prompt: str) -> str:
|
|
11
|
+
"""Ask for a secret without echoing it; an empty answer is an error."""
|
|
12
|
+
value = getpass.getpass(prompt).strip()
|
|
13
|
+
if not value:
|
|
14
|
+
raise ValueError("Nothing entered")
|
|
15
|
+
return value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def confirm_overwrite(path: Path) -> bool:
|
|
19
|
+
"""True when the file does not exist, or the user agrees to replace it."""
|
|
20
|
+
if not path.exists():
|
|
21
|
+
return True
|
|
22
|
+
answer = input(f"{path} exists. Replace it? [y/N] ").strip().lower()
|
|
23
|
+
return answer in {"y", "yes"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def write_private(path: Path, text: str) -> None:
|
|
27
|
+
"""Write a credentials file (plain text) readable only by the user where the OS allows."""
|
|
28
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
29
|
+
path.write_text(text, encoding="utf-8")
|
|
30
|
+
with contextlib.suppress(OSError): # Windows ignores POSIX modes
|
|
31
|
+
path.chmod(0o600)
|