eodag 3.5.1__py3-none-any.whl → 3.6.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.
eodag/api/core.py CHANGED
@@ -69,6 +69,7 @@ from eodag.utils import (
69
69
  DEFAULT_MAX_ITEMS_PER_PAGE,
70
70
  DEFAULT_PAGE,
71
71
  GENERIC_PRODUCT_TYPE,
72
+ GENERIC_STAC_PROVIDER,
72
73
  HTTP_REQ_TIMEOUT,
73
74
  MockResponse,
74
75
  _deprecated,
@@ -2476,3 +2477,31 @@ class EODataAccessGateway:
2476
2477
  )
2477
2478
  # Remove the ID since this is equal to productType.
2478
2479
  plugin.config.product_type_config.pop("ID", None)
2480
+
2481
+ def import_stac_items(self, items_urls: list[str]) -> SearchResult:
2482
+ """Import STAC items from a list of URLs and convert them to SearchResult.
2483
+
2484
+ - Origin provider and download links will be set if item comes from an EODAG
2485
+ server.
2486
+ - If item comes from a known EODAG provider, result will be registered to it,
2487
+ ready to download and its metadata normalized.
2488
+ - If item comes from an unknown provider, a generic STAC provider will be used.
2489
+
2490
+ :param items_urls: A list of STAC items URLs to import
2491
+ :returns: A SearchResult containing the imported STAC items
2492
+ """
2493
+ json_items = []
2494
+ for item_url in items_urls:
2495
+ json_items.extend(fetch_stac_items(item_url))
2496
+
2497
+ # add a generic STAC provider that might be needed to handle the items
2498
+ self.add_provider(GENERIC_STAC_PROVIDER)
2499
+
2500
+ results = SearchResult([])
2501
+ for json_item in json_items:
2502
+ if search_result := SearchResult._from_stac_item(
2503
+ json_item, self._plugins_manager
2504
+ ):
2505
+ results.extend(search_result)
2506
+
2507
+ return results
@@ -17,6 +17,12 @@
17
17
  # limitations under the License.
18
18
  #
19
19
  """EODAG product package"""
20
+
21
+ from typing import TYPE_CHECKING, Any, Optional
22
+
23
+ if TYPE_CHECKING:
24
+ from eodag.plugins.manager import PluginManager
25
+
20
26
  try:
21
27
  # import from eodag-cube if installed
22
28
  from eodag_cube.api.product import ( # pyright: ignore[reportMissingImports]
@@ -30,3 +36,27 @@ except ImportError:
30
36
 
31
37
  # exportable content
32
38
  __all__ = ["Asset", "AssetsDict", "EOProduct"]
39
+
40
+
41
+ def unregistered_product_from_item(
42
+ feature: dict[str, Any], provider: str, plugins_manager: "PluginManager"
43
+ ) -> Optional[EOProduct]:
44
+ """Create an EOProduct from a STAC item, map its metadata, but without registering its plugins.
45
+
46
+ :param feature: The STAC item to convert into an EOProduct.
47
+ :param provider: The associated provider from which configuration should be used for mapping.
48
+ :param plugins_manager: The plugins manager instance to use for retrieving search plugins.
49
+ :returns: An EOProduct instance if the item can be normalized, otherwise None.
50
+ """
51
+ for search_plugin in plugins_manager.get_search_plugins(provider=provider):
52
+ if hasattr(search_plugin, "normalize_results"):
53
+ products = search_plugin.normalize_results([feature])
54
+ if len(products) > 0:
55
+ # properties cleanup
56
+ for prop in ("start_datetime", "end_datetime"):
57
+ products[0].properties.pop(prop, None)
58
+ # set product type if not already set
59
+ if products[0].product_type is None:
60
+ products[0].product_type = products[0].properties.get("productType")
61
+ return products[0]
62
+ return None
@@ -17,23 +17,30 @@
17
17
  # limitations under the License.
18
18
  from __future__ import annotations
19
19
 
20
+ import logging
20
21
  from collections import UserList
21
22
  from typing import TYPE_CHECKING, Annotated, Any, Iterable, Optional, Union
22
23
 
23
24
  from shapely.geometry import GeometryCollection, shape
24
25
  from typing_extensions import Doc
25
26
 
26
- from eodag.api.product import EOProduct
27
+ from eodag.api.product import EOProduct, unregistered_product_from_item
27
28
  from eodag.plugins.crunch.filter_date import FilterDate
28
29
  from eodag.plugins.crunch.filter_latest_intersect import FilterLatestIntersect
29
30
  from eodag.plugins.crunch.filter_latest_tpl_name import FilterLatestByName
30
31
  from eodag.plugins.crunch.filter_overlap import FilterOverlap
31
32
  from eodag.plugins.crunch.filter_property import FilterProperty
33
+ from eodag.utils import GENERIC_STAC_PROVIDER, STAC_SEARCH_PLUGINS
34
+ from eodag.utils.exceptions import MisconfiguredError
32
35
 
33
36
  if TYPE_CHECKING:
34
37
  from shapely.geometry.base import BaseGeometry
35
38
 
36
39
  from eodag.plugins.crunch.base import Crunch
40
+ from eodag.plugins.manager import PluginManager
41
+
42
+
43
+ logger = logging.getLogger("eodag.search_result")
37
44
 
38
45
 
39
46
  class SearchResult(UserList[EOProduct]):
@@ -211,6 +218,153 @@ class SearchResult(UserList[EOProduct]):
211
218
 
212
219
  return super().extend(other)
213
220
 
221
+ @classmethod
222
+ def _from_stac_item(
223
+ cls, feature: dict[str, Any], plugins_manager: PluginManager
224
+ ) -> SearchResult:
225
+ """Create a SearchResult from a STAC item.
226
+
227
+ :param feature: A STAC item as a dictionary
228
+ :param plugins_manager: The EODAG plugin manager instance
229
+ :returns: A SearchResult containing the EOProduct(s) created from the STAC item
230
+ """
231
+ # Try importing from EODAG Server
232
+ if results := _import_stac_item_from_eodag_server(feature, plugins_manager):
233
+ return results
234
+
235
+ # try importing from a known STAC provider
236
+ if results := _import_stac_item_from_known_provider(feature, plugins_manager):
237
+ return results
238
+
239
+ # try importing from an unknown STAC provider
240
+ return _import_stac_item_from_unknown_provider(feature, plugins_manager)
241
+
242
+
243
+ def _import_stac_item_from_eodag_server(
244
+ feature: dict[str, Any], plugins_manager: PluginManager
245
+ ) -> Optional[SearchResult]:
246
+ """Import a STAC item from EODAG Server.
247
+
248
+ :param feature: A STAC item as a dictionary
249
+ :param plugins_manager: The EODAG plugin manager instance
250
+ :returns: A SearchResult containing the EOProduct(s) created from the STAC item
251
+ """
252
+ provider = None
253
+ if backends := feature["properties"].get("federation:backends"):
254
+ provider = backends[0]
255
+ elif providers := feature["properties"].get("providers"):
256
+ provider = providers[0].get("name")
257
+ if provider is not None:
258
+ logger.debug("Trying to import STAC item from EODAG Server")
259
+ # assets coming from a STAC provider
260
+ assets = {
261
+ k: v["alternate"]["origin"]
262
+ for k, v in feature.get("assets", {}).items()
263
+ if k not in ("thumbnail", "downloadLink")
264
+ and "origin" in v.get("alternate", {})
265
+ }
266
+ if assets:
267
+ updated_item = {**feature, **{"assets": assets}}
268
+ else:
269
+ # item coming from a non-STAC provider
270
+ updated_item = {**feature}
271
+ download_link = (
272
+ feature.get("assets", {})
273
+ .get("downloadLink", {})
274
+ .get("alternate", {})
275
+ .get("origin", {})
276
+ .get("href")
277
+ )
278
+ if download_link:
279
+ updated_item["assets"] = {}
280
+ updated_item["links"] = [{"rel": "self", "href": download_link}]
281
+ else:
282
+ updated_item = {}
283
+ try:
284
+ eo_product = unregistered_product_from_item(
285
+ updated_item, GENERIC_STAC_PROVIDER, plugins_manager
286
+ )
287
+ except MisconfiguredError:
288
+ eo_product = None
289
+ if eo_product is not None:
290
+ eo_product.provider = provider
291
+ eo_product._register_downloader_from_manager(plugins_manager)
292
+ return SearchResult([eo_product])
293
+ return None
294
+
295
+
296
+ def _import_stac_item_from_known_provider(
297
+ feature: dict[str, Any], plugins_manager: PluginManager
298
+ ) -> Optional[SearchResult]:
299
+ """Import a STAC item from an already-configured STAC provider.
300
+
301
+ :param feature: A STAC item as a dictionary
302
+ :param plugins_manager: The EODAG plugin manager instance
303
+ :returns: A SearchResult containing the EOProduct(s) created from the STAC item
304
+ """
305
+ item_hrefs = [f for f in feature.get("links", []) if f.get("rel") == "self"]
306
+ item_href = item_hrefs[0]["href"] if len(item_hrefs) > 0 else None
307
+ imported_products = SearchResult([])
308
+ for search_plugin in plugins_manager.get_search_plugins():
309
+ # only try STAC search plugins
310
+ if (
311
+ search_plugin.config.type in STAC_SEARCH_PLUGINS
312
+ and search_plugin.provider != GENERIC_STAC_PROVIDER
313
+ and hasattr(search_plugin, "normalize_results")
314
+ ):
315
+ provider_base_url = search_plugin.config.api_endpoint.removesuffix(
316
+ "/search"
317
+ )
318
+ # compare the item href with the provider base URL
319
+ if item_href and item_href.startswith(provider_base_url):
320
+ products = search_plugin.normalize_results([feature])
321
+ if len(products) == 0 or len(products[0].assets) == 0:
322
+ continue
323
+ logger.debug(
324
+ "Trying to import STAC item from %s", search_plugin.provider
325
+ )
326
+ eo_product = products[0]
327
+
328
+ configured_pts = [
329
+ k
330
+ for k, v in search_plugin.config.products.items()
331
+ if v.get("productType") == feature.get("collection")
332
+ ]
333
+ if len(configured_pts) > 0:
334
+ eo_product.product_type = configured_pts[0]
335
+ else:
336
+ eo_product.product_type = feature.get("collection")
337
+
338
+ eo_product._register_downloader_from_manager(plugins_manager)
339
+ imported_products.append(eo_product)
340
+ if len(imported_products) > 0:
341
+ return imported_products
342
+ return None
343
+
344
+
345
+ def _import_stac_item_from_unknown_provider(
346
+ feature: dict[str, Any], plugins_manager: PluginManager
347
+ ) -> SearchResult:
348
+ """Import a STAC item from an unknown STAC provider.
349
+
350
+ :param feature: A STAC item as a dictionary
351
+ :param plugins_manager: The EODAG plugin manager instance
352
+ :returns: A SearchResult containing the EOProduct(s) created from the STAC item
353
+ """
354
+ try:
355
+ logger.debug("Trying to import STAC item from unknown provider")
356
+ eo_product = unregistered_product_from_item(
357
+ feature, GENERIC_STAC_PROVIDER, plugins_manager
358
+ )
359
+ except MisconfiguredError:
360
+ pass
361
+ if eo_product is not None:
362
+ eo_product.product_type = feature.get("collection")
363
+ eo_product._register_downloader_from_manager(plugins_manager)
364
+ return SearchResult([eo_product])
365
+ else:
366
+ return SearchResult([])
367
+
214
368
 
215
369
  class RawSearchResult(UserList[dict[str, Any]]):
216
370
  """An object representing a collection of raw/unparsed search results obtained from a provider.
eodag/cli.py CHANGED
@@ -52,7 +52,7 @@ from typing import TYPE_CHECKING, Any, Mapping
52
52
 
53
53
  import click
54
54
 
55
- from eodag.api.core import EODataAccessGateway
55
+ from eodag.api.core import EODataAccessGateway, SearchResult
56
56
  from eodag.utils import DEFAULT_ITEMS_PER_PAGE, DEFAULT_PAGE, parse_qs
57
57
  from eodag.utils.exceptions import NoMatchingProductType, UnsupportedProvider
58
58
  from eodag.utils.logging import setup_logging
@@ -116,7 +116,7 @@ class MutuallyExclusiveOption(click.Option):
116
116
  return super(MutuallyExclusiveOption, self).handle_parse_result(ctx, opts, args)
117
117
 
118
118
 
119
- @click.group()
119
+ @click.group(chain=True)
120
120
  @click.option(
121
121
  "-v",
122
122
  "--verbose",
@@ -212,6 +212,18 @@ def version() -> None:
212
212
  "-S", "--sensorType", help="Search for products matching this type of sensor"
213
213
  )
214
214
  @click.option("--id", help="Search for the product identified by this id")
215
+ @click.option(
216
+ "--locations",
217
+ type=str,
218
+ help="Custom query-string argument(s) to select locations. "
219
+ "Format :'key1=value1&key2=value2'. Example: --locations country=FRA&continent=Africa",
220
+ )
221
+ @click.option(
222
+ "-q",
223
+ "--query",
224
+ type=str,
225
+ help="Custom query-string argument(s). Format :'key1=value1&key2=value2'",
226
+ )
215
227
  @click.option(
216
228
  "--cruncher",
217
229
  type=click.Choice(CRUNCHERS),
@@ -262,19 +274,9 @@ def version() -> None:
262
274
  @click.option(
263
275
  "--count",
264
276
  is_flag=True,
265
- help="Whether to run a query with a count request or not.",
266
- )
267
- @click.option(
268
- "--locations",
269
- type=str,
270
- help="Custom query-string argument(s) to select locations. "
271
- "Format :'key1=value1&key2=value2'. Example: --locations country=FRA&continent=Africa",
272
- )
273
- @click.option(
274
- "-q",
275
- "--query",
276
- type=str,
277
- help="Custom query-string argument(s). Format :'key1=value1&key2=value2'",
277
+ help="Make a count request together with search (Enabling count will significantly "
278
+ "slow down search requests for some providers, and might be unavailable for some"
279
+ "others).",
278
280
  )
279
281
  @click.pass_context
280
282
  def search_crunch(ctx: Context, **kwargs: Any) -> None:
@@ -407,6 +409,7 @@ def search_crunch(ctx: Context, **kwargs: Any) -> None:
407
409
  storage_filepath += ".geojson"
408
410
  result_storage = gateway.serialize(results, filename=storage_filepath)
409
411
  click.echo("Results stored at '{}'".format(result_storage))
412
+ ctx.obj["search_results"] = results
410
413
 
411
414
 
412
415
  @eodag.command(name="list", help="List supported product types")
@@ -528,12 +531,26 @@ def discover_pt(ctx: Context, **kwargs: Any) -> None:
528
531
  click.echo("Results stored at '{}'".format(storage_filepath))
529
532
 
530
533
 
531
- @eodag.command(help="Download a list of products from a serialized search result")
534
+ @eodag.command(
535
+ help="""Download a list of products from a serialized search result or STAC items URLs/paths
536
+
537
+ Examples:
538
+
539
+ eodag download --search-results /path/to/search_results.geojson
540
+
541
+ eodag download --stac-item https://example.com/stac/item1.json --stac-item /path/to/item2.json
542
+ """,
543
+ )
532
544
  @click.option(
533
545
  "--search-results",
534
546
  type=click.Path(exists=True, dir_okay=False),
535
547
  help="Path to a serialized search result",
536
548
  )
549
+ @click.option(
550
+ "--stac-item",
551
+ multiple=True,
552
+ help="URL/path of a STAC item to download (multiple values accepted)",
553
+ )
537
554
  @click.option(
538
555
  "-f",
539
556
  "--conf",
@@ -546,13 +563,20 @@ def discover_pt(ctx: Context, **kwargs: Any) -> None:
546
563
  show_default=False,
547
564
  help="Download only quicklooks of products instead full set of files",
548
565
  )
566
+ @click.option(
567
+ "--output-dir",
568
+ type=click.Path(dir_okay=True, file_okay=False),
569
+ help="Products or quicklooks download directory (Default: local temporary directory)",
570
+ )
549
571
  @click.pass_context
550
572
  def download(ctx: Context, **kwargs: Any) -> None:
551
573
  """Download a bunch of products from a serialized search result"""
552
574
  search_result_path = kwargs.pop("search_results")
553
- if not search_result_path:
575
+ stac_items = kwargs.pop("stac_item")
576
+ search_results = ctx.obj.get("search_results")
577
+ if not search_result_path and not stac_items and search_results is None:
554
578
  with click.Context(download) as ctx:
555
- click.echo("Nothing to do (no search results file provided)")
579
+ click.echo("Nothing to do (no search results file or stac item provided)")
556
580
  click.echo(download.get_help(ctx))
557
581
  sys.exit(1)
558
582
  setup_logging(verbose=ctx.obj["verbosity"])
@@ -561,25 +585,24 @@ def download(ctx: Context, **kwargs: Any) -> None:
561
585
  conf_file = click.format_filename(conf_file)
562
586
 
563
587
  satim_api = EODataAccessGateway(user_conf_file_path=conf_file)
564
- search_results = satim_api.deserialize(search_result_path)
565
588
 
589
+ search_results = search_results or SearchResult([])
590
+ if search_result_path:
591
+ search_results.extend(satim_api.deserialize_and_register(search_result_path))
592
+ if stac_items:
593
+ search_results.extend(satim_api.import_stac_items(list(stac_items)))
594
+
595
+ output_dir = kwargs.pop("output_dir")
566
596
  get_quicklooks = kwargs.pop("quicklooks")
597
+
567
598
  if get_quicklooks:
599
+ # Download only quicklooks
568
600
  click.echo(
569
601
  "Flag 'quicklooks' specified, downloading only quicklooks of products"
570
602
  )
571
603
 
572
604
  for idx, product in enumerate(search_results):
573
- if product.downloader is None:
574
- downloader = satim_api._plugins_manager.get_download_plugin(product)
575
- auth = product.downloader_auth
576
- if auth is None:
577
- auth = satim_api._plugins_manager.get_auth_plugin(
578
- downloader, product
579
- )
580
- search_results[idx].register_downloader(downloader, auth)
581
-
582
- downloaded_file = product.get_quicklook()
605
+ downloaded_file = product.get_quicklook(output_dir=output_dir)
583
606
  if not downloaded_file:
584
607
  click.echo(
585
608
  "A quicklook may have been downloaded but we cannot locate it. "
@@ -589,18 +612,8 @@ def download(ctx: Context, **kwargs: Any) -> None:
589
612
  click.echo("Downloaded {}".format(downloaded_file))
590
613
 
591
614
  else:
592
- # register downloader
593
- for idx, product in enumerate(search_results):
594
- if product.downloader is None:
595
- downloader = satim_api._plugins_manager.get_download_plugin(product)
596
- auth = product.downloader_auth
597
- if auth is None:
598
- auth = satim_api._plugins_manager.get_auth_plugin(
599
- downloader, product
600
- )
601
- search_results[idx].register_downloader(downloader, auth)
602
-
603
- downloaded_files = satim_api.download_all(search_results)
615
+ # Download products
616
+ downloaded_files = satim_api.download_all(search_results, output_dir=output_dir)
604
617
  if downloaded_files and len(downloaded_files) > 0:
605
618
  for downloaded_file in downloaded_files:
606
619
  if downloaded_file is None:
eodag/config.py CHANGED
@@ -46,6 +46,7 @@ from jsonpath_ng import JSONPath
46
46
  from eodag.api.product.metadata_mapping import mtd_cfg_as_conversion_and_querypath
47
47
  from eodag.utils import (
48
48
  HTTP_REQ_TIMEOUT,
49
+ STAC_SEARCH_PLUGINS,
49
50
  USER_AGENT,
50
51
  cached_yaml_load,
51
52
  cached_yaml_load_all,
@@ -819,12 +820,7 @@ def provider_config_init(
819
820
  if (
820
821
  stac_search_default_conf is not None
821
822
  and provider_config.search
822
- and provider_config.search.type
823
- in [
824
- "StacSearch",
825
- "StacListAssets",
826
- "StaticStacSearch",
827
- ]
823
+ and provider_config.search.type in STAC_SEARCH_PLUGINS
828
824
  ):
829
825
  # search config set to stac defaults overriden with provider config
830
826
  per_provider_stac_provider_config = deepcopy(stac_search_default_conf)
@@ -25,6 +25,7 @@ import tarfile
25
25
  import tempfile
26
26
  import zipfile
27
27
  from datetime import datetime, timedelta
28
+ from pathlib import Path
28
29
  from time import sleep
29
30
  from typing import TYPE_CHECKING, Any, Callable, Optional, TypeVar, Union
30
31
 
@@ -232,12 +233,23 @@ class Download(PluginTopic):
232
233
  )
233
234
  if os.path.isfile(old_record_filename):
234
235
  os.rename(old_record_filename, record_filename)
235
- if os.path.isfile(record_filename) and os.path.isfile(fs_path):
236
+
237
+ # path with or without extension
238
+ path_obj = Path(fs_path)
239
+ matched_paths = list(path_obj.parent.glob(f"{path_obj.stem}.*"))
240
+ fs_path_with_ext = matched_paths[0] if matched_paths else fs_path
241
+ if (
242
+ os.path.isfile(record_filename)
243
+ and fs_path_with_ext
244
+ and os.path.isfile(fs_path_with_ext)
245
+ ):
236
246
  logger.info(
237
- f"Product already downloaded: {fs_path}",
247
+ f"Product already downloaded: {fs_path_with_ext}",
238
248
  )
239
249
  return (
240
- self._finalize(fs_path, progress_callback=progress_callback, **kwargs),
250
+ self._finalize(
251
+ str(fs_path_with_ext), progress_callback=progress_callback, **kwargs
252
+ ),
241
253
  None,
242
254
  )
243
255
  elif os.path.isfile(record_filename) and os.path.isdir(fs_dir_path):
@@ -317,17 +317,17 @@ def append_time(input_date: date, time: Optional[str]) -> datetime:
317
317
 
318
318
 
319
319
  def parse_date(
320
- date_str: str, time: Optional[Union[str, list[str]]]
320
+ date: str, time: Optional[Union[str, list[str]]]
321
321
  ) -> tuple[datetime, datetime]:
322
322
  """Parses a date string in formats YYYY-MM-DD, YYYMMDD, solo or in start/end or start/to/end intervals."""
323
- if "to" in date_str:
324
- start_date_str, end_date_str = date_str.split("/to/")
325
- elif "/" in date_str:
326
- dates = date_str.split("/")
323
+ if "to" in date:
324
+ start_date_str, end_date_str = date.split("/to/")
325
+ elif "/" in date:
326
+ dates = date.split("/")
327
327
  start_date_str = dates[0]
328
328
  end_date_str = dates[-1]
329
329
  else:
330
- start_date_str = end_date_str = date_str
330
+ start_date_str = end_date_str = date
331
331
 
332
332
  # Update YYYYMMDD formatted dates
333
333
  if re.match(r"^\d{8}$", start_date_str):
@@ -401,6 +401,8 @@ def ecmwf_temporal_to_eodag(
401
401
  start = end = None
402
402
 
403
403
  if date := params.get("date"):
404
+ if isinstance(date, list):
405
+ date = "/".join(date)
404
406
  start, end = parse_date(date, params.get("time"))
405
407
 
406
408
  elif year := (params.get("year") or params.get("hyear")):
@@ -1173,7 +1173,10 @@ class QueryStringSearch(Search):
1173
1173
 
1174
1174
  collection = getattr(self.config, "collection", None)
1175
1175
  if collection is None:
1176
- collection = prep.product_type_def_params.get("collection") or product_type
1176
+ collection = (
1177
+ getattr(prep, "product_type_def_params", {}).get("collection")
1178
+ or product_type
1179
+ )
1177
1180
 
1178
1181
  if collection is None:
1179
1182
  return ()