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.
Files changed (47) hide show
  1. eo_fetch/__init__.py +48 -0
  2. eo_fetch/__main__.py +3 -0
  3. eo_fetch/api.py +288 -0
  4. eo_fetch/backends/__init__.py +71 -0
  5. eo_fetch/backends/base.py +28 -0
  6. eo_fetch/backends/cds.py +174 -0
  7. eo_fetch/backends/credentials.py +31 -0
  8. eo_fetch/backends/ecmwf.py +135 -0
  9. eo_fetch/backends/esgf.py +231 -0
  10. eo_fetch/backends/eumetsat.py +172 -0
  11. eo_fetch/backends/http_index.py +161 -0
  12. eo_fetch/backends/local.py +45 -0
  13. eo_fetch/backends/marine.py +158 -0
  14. eo_fetch/backends/nasa.py +240 -0
  15. eo_fetch/backends/s3.py +136 -0
  16. eo_fetch/backends/stac.py +237 -0
  17. eo_fetch/bridge.py +53 -0
  18. eo_fetch/catalog/__init__.py +5 -0
  19. eo_fetch/catalog/copernicus_ecmwf.py +130 -0
  20. eo_fetch/catalog/descriptions.py +116 -0
  21. eo_fetch/catalog/dwd.py +50 -0
  22. eo_fetch/catalog/eumetsat.py +42 -0
  23. eo_fetch/catalog/nasa.py +37 -0
  24. eo_fetch/catalog/noaa.py +67 -0
  25. eo_fetch/catalog/stac.py +88 -0
  26. eo_fetch/checkreaders.py +142 -0
  27. eo_fetch/cli.py +469 -0
  28. eo_fetch/discovery.py +111 -0
  29. eo_fetch/downloader.py +112 -0
  30. eo_fetch/manifest.py +73 -0
  31. eo_fetch/models.py +56 -0
  32. eo_fetch/plotting.py +178 -0
  33. eo_fetch/readers/__init__.py +188 -0
  34. eo_fetch/readers/generic.py +83 -0
  35. eo_fetch/readers/radolan.py +112 -0
  36. eo_fetch/readers/satellite.py +170 -0
  37. eo_fetch/readers/unpack.py +81 -0
  38. eo_fetch/registry.py +70 -0
  39. eo_fetch/regrid.py +125 -0
  40. eo_fetch/styles.py +223 -0
  41. eo_fetch/timeguess.py +63 -0
  42. eo_fetch_api-1.0.0.dist-info/METADATA +230 -0
  43. eo_fetch_api-1.0.0.dist-info/RECORD +47 -0
  44. eo_fetch_api-1.0.0.dist-info/WHEEL +5 -0
  45. eo_fetch_api-1.0.0.dist-info/entry_points.txt +2 -0
  46. eo_fetch_api-1.0.0.dist-info/licenses/LICENSE +21 -0
  47. 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
@@ -0,0 +1,3 @@
1
+ from eo_fetch.cli import main
2
+
3
+ raise SystemExit(main())
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
+ ...
@@ -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)