eodag 4.0.0a1__py3-none-any.whl → 4.0.0a2__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 (37) hide show
  1. eodag/__init__.py +6 -1
  2. eodag/api/collection.py +353 -0
  3. eodag/api/core.py +308 -296
  4. eodag/api/product/_product.py +15 -29
  5. eodag/api/product/drivers/__init__.py +2 -42
  6. eodag/api/product/drivers/base.py +0 -11
  7. eodag/api/product/metadata_mapping.py +34 -5
  8. eodag/api/search_result.py +144 -9
  9. eodag/cli.py +18 -15
  10. eodag/config.py +37 -3
  11. eodag/plugins/apis/ecmwf.py +16 -4
  12. eodag/plugins/apis/usgs.py +18 -7
  13. eodag/plugins/crunch/filter_latest_intersect.py +1 -0
  14. eodag/plugins/crunch/filter_overlap.py +3 -7
  15. eodag/plugins/search/__init__.py +3 -0
  16. eodag/plugins/search/base.py +6 -6
  17. eodag/plugins/search/build_search_result.py +157 -56
  18. eodag/plugins/search/cop_marine.py +48 -8
  19. eodag/plugins/search/csw.py +18 -8
  20. eodag/plugins/search/qssearch.py +331 -88
  21. eodag/plugins/search/static_stac_search.py +11 -12
  22. eodag/resources/collections.yml +610 -348
  23. eodag/resources/ext_collections.json +1 -1
  24. eodag/resources/ext_product_types.json +1 -1
  25. eodag/resources/providers.yml +330 -58
  26. eodag/resources/stac_provider.yml +4 -2
  27. eodag/resources/user_conf_template.yml +9 -0
  28. eodag/types/__init__.py +2 -0
  29. eodag/types/queryables.py +16 -0
  30. eodag/utils/__init__.py +47 -2
  31. eodag/utils/repr.py +2 -0
  32. {eodag-4.0.0a1.dist-info → eodag-4.0.0a2.dist-info}/METADATA +4 -2
  33. {eodag-4.0.0a1.dist-info → eodag-4.0.0a2.dist-info}/RECORD +37 -36
  34. {eodag-4.0.0a1.dist-info → eodag-4.0.0a2.dist-info}/WHEEL +0 -0
  35. {eodag-4.0.0a1.dist-info → eodag-4.0.0a2.dist-info}/entry_points.txt +0 -0
  36. {eodag-4.0.0a1.dist-info → eodag-4.0.0a2.dist-info}/licenses/LICENSE +0 -0
  37. {eodag-4.0.0a1.dist-info → eodag-4.0.0a2.dist-info}/top_level.txt +0 -0
@@ -38,7 +38,8 @@ try:
38
38
  except ImportError:
39
39
  from eodag.api.product._assets import AssetsDict
40
40
 
41
- from eodag.api.product.drivers import DRIVERS, LEGACY_DRIVERS, NoDriver
41
+ from eodag.api.product.drivers import DRIVERS
42
+ from eodag.api.product.drivers.generic import GenericDriver
42
43
  from eodag.api.product.metadata_mapping import (
43
44
  DEFAULT_GEOMETRY,
44
45
  NOT_AVAILABLE,
@@ -48,6 +49,7 @@ from eodag.api.product.metadata_mapping import (
48
49
  from eodag.utils import (
49
50
  DEFAULT_DOWNLOAD_TIMEOUT,
50
51
  DEFAULT_DOWNLOAD_WAIT,
52
+ DEFAULT_SHAPELY_GEOMETRY,
51
53
  DEFAULT_STREAM_REQUESTS_TIMEOUT,
52
54
  USER_AGENT,
53
55
  ProgressCallback,
@@ -68,12 +70,6 @@ if TYPE_CHECKING:
68
70
  from eodag.types.download_args import DownloadConf
69
71
  from eodag.utils import Unpack
70
72
 
71
- try:
72
- from shapely.errors import GEOSException
73
- except ImportError:
74
- # shapely < 2.0 compatibility
75
- from shapely.errors import TopologicalError as GEOSException
76
-
77
73
 
78
74
  logger = logging.getLogger("eodag.product")
79
75
 
@@ -142,6 +138,7 @@ class EOProduct:
142
138
  and value != NOT_MAPPED
143
139
  and NOT_AVAILABLE not in str(value)
144
140
  and not key.startswith("_")
141
+ and value is not None
145
142
  }
146
143
  common_stac_properties = {
147
144
  key: self.properties[key]
@@ -160,9 +157,7 @@ class EOProduct:
160
157
  )
161
158
  and "eodag:default_geometry" not in properties
162
159
  ):
163
- raise MisconfiguredError(
164
- f"No geometry available to build EOProduct(id={properties.get('id')}, provider={provider})"
165
- )
160
+ product_geometry = DEFAULT_SHAPELY_GEOMETRY
166
161
  elif not properties["geometry"] or properties["geometry"] == NOT_AVAILABLE:
167
162
  product_geometry = properties.pop(
168
163
  "eodag:default_geometry", DEFAULT_GEOMETRY
@@ -170,9 +165,11 @@ class EOProduct:
170
165
  else:
171
166
  product_geometry = properties["geometry"]
172
167
 
173
- self.geometry = self.search_intersection = get_geometry_from_various(
174
- geometry=product_geometry
175
- )
168
+ geometry_obj = get_geometry_from_various(geometry=product_geometry)
169
+ # whole world as default geometry
170
+ if geometry_obj is None:
171
+ geometry_obj = DEFAULT_SHAPELY_GEOMETRY
172
+ self.geometry = self.search_intersection = geometry_obj
176
173
 
177
174
  self.search_kwargs = kwargs
178
175
  if self.search_kwargs.get("geometry") is not None:
@@ -181,7 +178,7 @@ class EOProduct:
181
178
  )
182
179
  try:
183
180
  self.search_intersection = self.geometry.intersection(searched_geom)
184
- except (GEOSException, ShapelyError):
181
+ except ShapelyError:
185
182
  logger.warning(
186
183
  "Unable to intersect the requested extent: %s with the product "
187
184
  "geometry: %s",
@@ -597,21 +594,10 @@ class EOProduct:
597
594
 
598
595
  def get_driver(self) -> DatasetDriver:
599
596
  """Get the most appropriate driver"""
600
- try:
601
- for driver_conf in DRIVERS:
602
- if all([criteria(self) for criteria in driver_conf["criteria"]]):
603
- driver = driver_conf["driver"]
604
- break
605
- # use legacy driver for deprecated get_data method usage
606
- for lecacy_conf in LEGACY_DRIVERS:
607
- if all([criteria(self) for criteria in lecacy_conf["criteria"]]):
608
- driver.legacy = lecacy_conf["driver"]
609
- break
610
- return driver
611
- except TypeError:
612
- logger.info("No driver matching")
613
- pass
614
- return NoDriver()
597
+ for driver_conf in DRIVERS:
598
+ if all([criteria(self) for criteria in driver_conf["criteria"]]):
599
+ return driver_conf["driver"]
600
+ return GenericDriver()
615
601
 
616
602
  def _repr_html_(self):
617
603
  thumbnail = self.properties.get("eodag:thumbnail") or self.properties.get(
@@ -20,27 +20,11 @@ from __future__ import annotations
20
20
 
21
21
  from typing import Callable, TypedDict
22
22
 
23
- from eodag.api.product.drivers.base import DatasetDriver, NoDriver
23
+ from eodag.api.product.drivers.base import DatasetDriver
24
24
  from eodag.api.product.drivers.generic import GenericDriver
25
25
  from eodag.api.product.drivers.sentinel1 import Sentinel1Driver
26
26
  from eodag.api.product.drivers.sentinel2 import Sentinel2Driver
27
27
 
28
- try:
29
- # import from eodag-cube if installed
30
- from eodag_cube.api.product.drivers.generic import ( # pyright: ignore[reportMissingImports]; isort: skip
31
- GenericDriver as GenericDriver_cube,
32
- )
33
- from eodag_cube.api.product.drivers.sentinel2_l1c import ( # pyright: ignore[reportMissingImports]; isort: skip
34
- Sentinel2L1C as Sentinel2L1C_cube,
35
- )
36
- from eodag_cube.api.product.drivers.stac_assets import ( # pyright: ignore[reportMissingImports]; isort: skip
37
- StacAssets as StacAssets_cube,
38
- )
39
- except ImportError:
40
- GenericDriver_cube = NoDriver
41
- Sentinel2L1C_cube = NoDriver
42
- StacAssets_cube = NoDriver
43
-
44
28
 
45
29
  class DriverCriteria(TypedDict):
46
30
  """Driver criteria definition"""
@@ -76,29 +60,5 @@ DRIVERS: list[DriverCriteria] = [
76
60
  ]
77
61
 
78
62
 
79
- #: list of legacy drivers and their criteria
80
- LEGACY_DRIVERS: list[DriverCriteria] = [
81
- {
82
- "criteria": [
83
- lambda prod: True if len(getattr(prod, "assets", {})) > 0 else False
84
- ],
85
- "driver": StacAssets_cube(),
86
- },
87
- {
88
- "criteria": [lambda prod: True if "assets" in prod.properties else False],
89
- "driver": StacAssets_cube(),
90
- },
91
- {
92
- "criteria": [
93
- lambda prod: True if getattr(prod, "collection") == "S2_MSI_L1C" else False
94
- ],
95
- "driver": Sentinel2L1C_cube(),
96
- },
97
- {
98
- "criteria": [lambda prod: True],
99
- "driver": GenericDriver_cube(),
100
- },
101
- ]
102
-
103
63
  # exportable content
104
- __all__ = ["DRIVERS", "DatasetDriver", "GenericDriver", "NoDriver", "Sentinel2Driver"]
64
+ __all__ = ["DRIVERS", "DatasetDriver", "GenericDriver", "Sentinel2Driver"]
@@ -44,9 +44,6 @@ class DatasetDriver(metaclass=type):
44
44
  criteria.
45
45
  """
46
46
 
47
- #: legacy driver for deprecated :meth:`~eodag_cube.api.product._product.EOProduct.get_data` method usage
48
- legacy: DatasetDriver
49
-
50
47
  #: list of patterns to match asset keys and roles
51
48
  ASSET_KEYS_PATTERNS_ROLES: list[AssetPatterns] = []
52
49
 
@@ -79,11 +76,3 @@ class DatasetDriver(metaclass=type):
79
76
  return normalized_key or extracted_key, roles
80
77
  logger.debug(f"No key & roles could be guessed for {href}")
81
78
  return None, None
82
-
83
-
84
- class NoDriver(DatasetDriver):
85
- """A default :attr:`~eodag.api.product.drivers.base.DatasetDriver.legacy` driver that does not implement any of the
86
- methods it should implement, used for all collections for which the deprecated
87
- :meth:`~eodag_cube.api.product._product.EOProduct.get_data` method is not implemented. Expect a
88
- :exc:`NotImplementedError` when trying to get the data in that case.
89
- """
@@ -42,6 +42,7 @@ from shapely.ops import transform
42
42
  from eodag.types.queryables import Queryables
43
43
  from eodag.utils import (
44
44
  DEFAULT_PROJ,
45
+ DEFAULT_SHAPELY_GEOMETRY,
45
46
  deepcopy,
46
47
  dict_items_recursive_apply,
47
48
  format_string,
@@ -151,6 +152,7 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
151
152
 
152
153
  The currently understood converters are:
153
154
  - ``ceda_collection_name``: generate a CEDA collection name from a string
155
+ - ``wekeo_to_cop_collection``: converts the name of a collection from the WEkEO format to the Copernicus format
154
156
  - ``csv_list``: convert to a comma separated list
155
157
  - ``datetime_to_timestamp_milliseconds``: converts a utc date string to a timestamp in milliseconds
156
158
  - ``dict_filter_and_sub``: filter dict items using jsonpath and then apply recursive_sub_str
@@ -160,6 +162,7 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
160
162
  - ``from_georss``: convert GeoRSS to shapely geometry / WKT in DEFAULT_PROJ
161
163
  - ``get_ecmwf_time``: get the time of a datetime string in the ECMWF format
162
164
  - ``get_group_name``: get the matching regex group name
165
+ - ``literalize_unicode``: convert a string to its raw Unicode literal form
163
166
  - ``not_available``: replace value with "Not Available"
164
167
  - ``recursive_sub_str``: recursively substitue in the structure (e.g. dict) values matching a regex
165
168
  - ``remove_extension``: on a string that contains dots, only take the first part of the list obtained by
@@ -251,6 +254,9 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
251
254
  field_name = conversion_func_spec.groupdict()["field_name"]
252
255
  converter = conversion_func_spec.groupdict()["converter"]
253
256
  self.custom_args = conversion_func_spec.groupdict()["args"]
257
+ # converts back "_COLON_" to ":"
258
+ if self.custom_args is not None and "_COLON_" in self.custom_args:
259
+ self.custom_args = self.custom_args.replace("_COLON_", ":")
254
260
  self.custom_converter = getattr(self, "convert_{}".format(converter))
255
261
 
256
262
  return super(MetadataFormatter, self).get_field(field_name, args, kwargs)
@@ -383,14 +389,16 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
383
389
  @staticmethod
384
390
  def convert_to_bounds(input_geom_unformatted: Any) -> list[float]:
385
391
  input_geom = get_geometry_from_various(geometry=input_geom_unformatted)
392
+ if input_geom is None:
393
+ input_geom = DEFAULT_SHAPELY_GEOMETRY
386
394
  if isinstance(input_geom, MultiPolygon):
387
395
  geoms = [geom for geom in input_geom.geoms]
388
396
  # sort with larger one at first (stac-browser only plots first one)
389
397
  geoms.sort(key=lambda x: x.area, reverse=True)
390
- min_lon = 180
391
- min_lat = 90
392
- max_lon = -180
393
- max_lat = -90
398
+ min_lon = 180.0
399
+ min_lat = 90.0
400
+ max_lon = -180.0
401
+ max_lat = -90.0
394
402
  for geom in geoms:
395
403
  min_lon = min(min_lon, geom.bounds[0])
396
404
  min_lat = min(min_lat, geom.bounds[1])
@@ -661,7 +669,13 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
661
669
  match = data_regex.search(value)
662
670
  if match:
663
671
  return match.group("name").replace("/", "_").upper()
664
- return "NOT_AVAILABLE"
672
+ return NOT_AVAILABLE
673
+
674
+ @staticmethod
675
+ def convert_literalize_unicode(value: str) -> str:
676
+ if value == NOT_AVAILABLE:
677
+ return value
678
+ return value.encode("raw_unicode_escape").decode("utf-8")
665
679
 
666
680
  @staticmethod
667
681
  def convert_recursive_sub_str(
@@ -1051,6 +1065,11 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
1051
1065
  assets_dict[asset_basename] = assets_dict.pop(asset_name)
1052
1066
  return assets_dict
1053
1067
 
1068
+ @staticmethod
1069
+ def convert_wekeo_to_cop_collection(val: str, prefix: str) -> str:
1070
+ """Converts the name of a collection from the WEkEO format to the Copernicus format."""
1071
+ return val.removeprefix(prefix).lower().replace("_", "-")
1072
+
1054
1073
  # if stac extension colon separator `:` is in search params, parse it to prevent issues with vformat
1055
1074
  if re.search(r"{[\w-]*:[\w#-]*\(?.*}", search_param):
1056
1075
  search_param = re.sub(
@@ -1059,6 +1078,16 @@ def format_metadata(search_param: str, *args: Any, **kwargs: Any) -> str:
1059
1078
  search_param,
1060
1079
  )
1061
1080
  kwargs = {k.replace(":", "_COLON_"): v for k, v in kwargs.items()}
1081
+ # convert colons `:` in the parameters passed to the converter (e.g. 'foo#boo(fun:with:colons)')
1082
+ if re.search(r"{[\w-]*#[\w-]*\([^)]*:.*}", search_param):
1083
+ search_param = re.sub(
1084
+ r"({[\w-]*#[\w-]*)\(([^)]*)(.*})",
1085
+ lambda m: m.group(1)
1086
+ + "("
1087
+ + m.group(2).replace(":", "_COLON_")
1088
+ + m.group(3),
1089
+ search_param,
1090
+ )
1062
1091
 
1063
1092
  return MetadataFormatter().vformat(search_param, args, kwargs)
1064
1093
 
@@ -19,9 +19,11 @@ from __future__ import annotations
19
19
 
20
20
  import logging
21
21
  from collections import UserList
22
- from typing import TYPE_CHECKING, Annotated, Any, Iterable, Optional, Union
22
+ from typing import TYPE_CHECKING, Annotated, Any, Iterable, Iterator, Optional, Union
23
23
 
24
- from shapely.geometry import GeometryCollection, shape
24
+ from shapely.geometry import GeometryCollection
25
+ from shapely.geometry import mapping as shapely_mapping
26
+ from shapely.geometry import shape
25
27
  from typing_extensions import Doc
26
28
 
27
29
  from eodag.api.product import EOProduct, unregistered_product_from_item
@@ -36,6 +38,7 @@ from eodag.utils.exceptions import MisconfiguredError
36
38
  if TYPE_CHECKING:
37
39
  from shapely.geometry.base import BaseGeometry
38
40
 
41
+ from eodag.api.core import EODataAccessGateway
39
42
  from eodag.plugins.crunch.base import Crunch
40
43
  from eodag.plugins.manager import PluginManager
41
44
 
@@ -48,6 +51,11 @@ class SearchResult(UserList[EOProduct]):
48
51
 
49
52
  :param products: A list of products resulting from a search
50
53
  :param number_matched: (optional) the estimated total number of matching results
54
+ :param errors: (optional) stored errors encountered during the search. Tuple of (provider name, exception)
55
+ :param search_params: (optional) search parameters stored to use in pagination
56
+ :param next_page_token: (optional) next page token value to use in pagination
57
+ :param next_page_token_key: (optional) next page token key to use in pagination
58
+ :param raise_errors: (optional) whether to raise errors encountered during the search
51
59
 
52
60
  :cvar data: List of products
53
61
  :ivar number_matched: Estimated total number of matching results
@@ -62,10 +70,19 @@ class SearchResult(UserList[EOProduct]):
62
70
  products: list[EOProduct],
63
71
  number_matched: Optional[int] = None,
64
72
  errors: Optional[list[tuple[str, Exception]]] = None,
73
+ search_params: Optional[dict[str, Any]] = None,
74
+ next_page_token: Optional[str] = None,
75
+ next_page_token_key: Optional[str] = None,
76
+ raise_errors: Optional[bool] = False,
65
77
  ) -> None:
66
78
  super().__init__(products)
67
79
  self.number_matched = number_matched
68
80
  self.errors = errors if errors is not None else []
81
+ self.search_params = search_params
82
+ self.next_page_token = next_page_token
83
+ self.next_page_token_key = next_page_token_key
84
+ self.raise_errors = raise_errors
85
+ self._dag: Optional["EODataAccessGateway"] = None
69
86
 
70
87
  def crunch(self, cruncher: Crunch, **search_params: Any) -> SearchResult:
71
88
  """Do some crunching with the underlying EO products.
@@ -149,18 +166,46 @@ class SearchResult(UserList[EOProduct]):
149
166
  :param feature_collection: A collection representing a search result.
150
167
  :returns: An eodag representation of a search result
151
168
  """
169
+
170
+ products = [
171
+ EOProduct.from_geojson(feature)
172
+ for feature in feature_collection.get("features", [])
173
+ ]
174
+ props = feature_collection.get("metadata", {}) or {}
175
+
176
+ eodag_search_params = props.get("eodag:search_params", {})
177
+ if eodag_search_params and eodag_search_params.get("geometry"):
178
+ eodag_search_params["geometry"] = shape(eodag_search_params["geometry"])
179
+
152
180
  return SearchResult(
153
- [
154
- EOProduct.from_geojson(feature)
155
- for feature in feature_collection["features"]
156
- ]
181
+ products=products,
182
+ number_matched=props.get("eodag:number_matched"),
183
+ next_page_token=props.get("eodag:next_page_token"),
184
+ next_page_token_key=props.get("eodag:next_page_token_key"),
185
+ search_params=eodag_search_params or None,
186
+ raise_errors=props.get("eodag:raise_errors"),
157
187
  )
158
188
 
159
189
  def as_geojson_object(self) -> dict[str, Any]:
160
190
  """GeoJSON representation of SearchResult"""
191
+
192
+ geojson_search_params = {} | (self.search_params or {})
193
+ # search_params shapely geometry to wkt
194
+ if self.search_params and self.search_params.get("geometry"):
195
+ geojson_search_params["geometry"] = shapely_mapping(
196
+ self.search_params["geometry"]
197
+ )
198
+
161
199
  return {
162
200
  "type": "FeatureCollection",
163
201
  "features": [product.as_dict() for product in self],
202
+ "metadata": {
203
+ "eodag:number_matched": self.number_matched,
204
+ "eodag:next_page_token": self.next_page_token,
205
+ "eodag:next_page_token_key": self.next_page_token_key,
206
+ "eodag:search_params": geojson_search_params or None,
207
+ "eodag:raise_errors": self.raise_errors,
208
+ },
164
209
  }
165
210
 
166
211
  def as_shapely_geometry_object(self) -> GeometryCollection:
@@ -218,6 +263,93 @@ class SearchResult(UserList[EOProduct]):
218
263
 
219
264
  return super().extend(other)
220
265
 
266
+ def next_page(self, update: bool = True) -> Iterator[SearchResult]:
267
+ """
268
+ Retrieve and iterate over the next pages of search results, if available.
269
+
270
+ This method uses the current search parameters and next page token to request
271
+ additional results from the provider. If ``update`` is ``True``, the current ``SearchResult``
272
+ instance is updated with new products and pagination information as pages are fetched.
273
+
274
+ :param update: If ``True``, update the current ``SearchResult`` with new results.
275
+ :returns: An iterator yielding ``SearchResult`` objects for each subsequent page.
276
+
277
+ Example:
278
+
279
+ >>> first_page = SearchResult([]) # result of a search
280
+ >>> for new_results in first_page.next_page():
281
+ ... continue # do something with new_results
282
+ """
283
+
284
+ def get_next_page(current):
285
+ if current.search_params is None:
286
+ current.search_params = {}
287
+ # Update the next_page_token in the search params
288
+ current.search_params["next_page_token"] = current.next_page_token
289
+ current.search_params["next_page_token_key"] = current.next_page_token_key
290
+ # Ensure the provider is in the search params
291
+ if "provider" in current.search_params:
292
+ current.search_params["provider"] = current.search_params.get(
293
+ "provider", None
294
+ )
295
+ elif current.data and hasattr(current.data[-1], "provider"):
296
+ current.search_params["provider"] = current.data[-1].provider
297
+ search_plugins, search_kwargs = current._dag._prepare_search(
298
+ **current.search_params
299
+ )
300
+ # If number_matched was provided, ensure it is passed to the next search
301
+ if current.number_matched:
302
+ search_kwargs["number_matched"] = current.number_matched
303
+ for i, search_plugin in enumerate(search_plugins):
304
+ # validate no needed for next pages
305
+ search_kwargs["validate"] = False
306
+ return current._dag._do_search(
307
+ search_plugin,
308
+ raise_errors=self.raise_errors,
309
+ **search_kwargs,
310
+ )
311
+
312
+ # Do not iterate if there is no next page token
313
+ # or if the current one returned less than the maximum number of items asked for.
314
+ if self.next_page_token is None:
315
+ return
316
+
317
+ new_results = get_next_page(self)
318
+ old_results = self
319
+
320
+ while new_results.data:
321
+ # The products between two iterations are compared. If they
322
+ # are actually the same product, it means the iteration failed at
323
+ # progressing for some reason.
324
+ if (
325
+ old_results.data[0].properties["id"]
326
+ == new_results.data[0].properties["id"]
327
+ ):
328
+ logger.warning(
329
+ "Iterate over pages: stop iterating since the next page "
330
+ "appears to have the same products as in the previous one. "
331
+ "This provider may not implement pagination.",
332
+ )
333
+ break
334
+ if update:
335
+ self.data += new_results.data
336
+ self.search_params = new_results.search_params
337
+ self.next_page_token = new_results.next_page_token
338
+ self.next_page_token_key = new_results.next_page_token_key
339
+ yield new_results
340
+ # Stop iterating if there is no next page token
341
+ # or if the current one returned less than the maximum number of items asked for.
342
+ if (
343
+ new_results.next_page_token is None
344
+ or len(new_results) < new_results.search_params["items_per_page"]
345
+ ):
346
+ break
347
+ old_results = new_results
348
+ new_results = get_next_page(new_results)
349
+ if not new_results:
350
+ break
351
+ return
352
+
221
353
  @classmethod
222
354
  def _from_stac_item(
223
355
  cls, feature: dict[str, Any], plugins_manager: PluginManager
@@ -331,13 +463,13 @@ def _import_stac_item_from_known_provider(
331
463
  )
332
464
  eo_product = products[0]
333
465
 
334
- configured_pts = [
466
+ configured_cols = [
335
467
  k
336
468
  for k, v in search_plugin.config.products.items()
337
469
  if v.get("_collection") == feature.get("collection")
338
470
  ]
339
- if len(configured_pts) > 0:
340
- eo_product.collection = configured_pts[0]
471
+ if len(configured_cols) > 0:
472
+ eo_product.collection = configured_cols[0]
341
473
  else:
342
474
  eo_product.collection = feature.get("collection")
343
475
 
@@ -380,6 +512,9 @@ class RawSearchResult(UserList[dict[str, Any]]):
380
512
 
381
513
  query_params: dict[str, Any]
382
514
  collection_def_params: dict[str, Any]
515
+ search_params: dict[str, Any]
516
+ next_page_token: Optional[str] = None
517
+ next_page_token_key: Optional[str] = None
383
518
 
384
519
  def __init__(self, results: list[Any]) -> None:
385
520
  super(RawSearchResult, self).__init__(results)
eodag/cli.py CHANGED
@@ -49,6 +49,7 @@ from urllib.parse import parse_qs
49
49
 
50
50
  import click
51
51
 
52
+ from eodag.api.collection import CollectionsList
52
53
  from eodag.api.core import EODataAccessGateway, SearchResult
53
54
  from eodag.utils import DEFAULT_ITEMS_PER_PAGE, DEFAULT_PAGE
54
55
  from eodag.utils.exceptions import NoMatchingCollection, UnsupportedProvider
@@ -142,8 +143,8 @@ def version() -> None:
142
143
 
143
144
  @eodag.command(
144
145
  name="search",
145
- help="Search satellite images by their collections, instrument, platform, "
146
- "platform identifier, processing level or sensor type. It is mandatory to provide "
146
+ help="Search satellite images by their collections, instruments, constellation, "
147
+ "platform, processing level or sensor type. It is mandatory to provide "
147
148
  "at least one of the previous criteria for eodag to perform a search. "
148
149
  "Optionally crunch the search results before storing them in a geojson file",
149
150
  )
@@ -423,14 +424,14 @@ def search_crunch(ctx: Context, **kwargs: Any) -> None:
423
424
  "--no-fetch", is_flag=True, help="Do not fetch providers for new collections"
424
425
  )
425
426
  @click.pass_context
426
- def list_pt(ctx: Context, **kwargs: Any) -> None:
427
+ def list_col(ctx: Context, **kwargs: Any) -> None:
427
428
  """Print the list of supported collections"""
428
429
  setup_logging(verbose=ctx.obj["verbosity"])
429
430
  dag = EODataAccessGateway()
430
431
  provider = kwargs.pop("provider")
431
432
  fetch_providers = not kwargs.pop("no_fetch")
432
433
  text_wrapper = textwrap.TextWrapper()
433
- guessed_collections = []
434
+ guessed_collections = CollectionsList([])
434
435
  try:
435
436
  guessed_collections = dag.guess_collection(
436
437
  **kwargs,
@@ -457,22 +458,24 @@ def list_pt(ctx: Context, **kwargs: Any) -> None:
457
458
  sys.exit(1)
458
459
  try:
459
460
  if guessed_collections:
460
- collections = [
461
- pt
462
- for pt in dag.list_collections(
463
- provider=provider, fetch_providers=fetch_providers
464
- )
465
- if pt["ID"] in guessed_collections
466
- ]
461
+ collections = CollectionsList(
462
+ [
463
+ col
464
+ for col in dag.list_collections(
465
+ provider=provider, fetch_providers=fetch_providers
466
+ )
467
+ if col.id in [guessed_col.id for guessed_col in guessed_collections]
468
+ ]
469
+ )
467
470
  else:
468
471
  collections = dag.list_collections(
469
472
  provider=provider, fetch_providers=fetch_providers
470
473
  )
471
474
  click.echo("Listing available collections:")
472
475
  for collection in collections:
473
- click.echo("\n* {}: ".format(collection["ID"]))
474
- for prop, value in collection.items():
475
- if prop != "ID":
476
+ click.echo("\n* {}: ".format(collection.id))
477
+ for prop, value in collection.model_dump().items():
478
+ if prop != "id":
476
479
  text_wrapper.initial_indent = " - {}: ".format(prop)
477
480
  text_wrapper.subsequent_indent = " " * len(
478
481
  text_wrapper.initial_indent
@@ -498,7 +501,7 @@ def list_pt(ctx: Context, **kwargs: Any) -> None:
498
501
  "DEFAULT: ext_collections.json",
499
502
  )
500
503
  @click.pass_context
501
- def discover_pt(ctx: Context, **kwargs: Any) -> None:
504
+ def discover_col(ctx: Context, **kwargs: Any) -> None:
502
505
  """Fetch external collections configuration and save result"""
503
506
  setup_logging(verbose=ctx.obj["verbosity"])
504
507
  dag = EODataAccessGateway()
eodag/config.py CHANGED
@@ -38,7 +38,6 @@ from typing import (
38
38
  import orjson
39
39
  import requests
40
40
  import yaml
41
- import yaml.constructor
42
41
  import yaml.parser
43
42
  from annotated_types import Gt
44
43
  from jsonpath_ng import JSONPath
@@ -241,10 +240,15 @@ class PluginConfig(yaml.YAMLObject):
241
240
  next_page_url_tpl: str
242
241
  #: The query-object for POST pagination requests.
243
242
  next_page_query_obj: str
243
+ #: Next page token key used in pagination. Can be guessed from ``KNOWN_NEXT_PAGE_TOKEN_KEYS`` (but needed by
244
+ # ``stac-fastapi-eodag`` that cannot guess and will use ``page`` as default).
245
+ next_page_token_key: str
244
246
  #: The endpoint for counting the number of items satisfying a request
245
247
  count_endpoint: str
246
248
  #: Index of the starting page
247
249
  start_page: int
250
+ #: Key in the current page URL for the next page URL
251
+ parse_url_key: str
248
252
 
249
253
  class Sort(TypedDict):
250
254
  """Configuration for sort during search"""
@@ -300,7 +304,7 @@ class PluginConfig(yaml.YAMLObject):
300
304
  results_entry: Union[JSONPath, str]
301
305
  #: Mapping for the collection id
302
306
  generic_collection_id: str
303
- #: Mapping for collection metadata (e.g. ``abstract``, ``licence``) which can be parsed from the provider
307
+ #: Mapping for collection metadata (e.g. ``description``, ``license``) which can be parsed from the provider
304
308
  #: result
305
309
  generic_collection_parsable_metadata: dict[str, str]
306
310
  #: Mapping for collection properties which can be parsed from the result and are not collection metadata
@@ -311,7 +315,7 @@ class PluginConfig(yaml.YAMLObject):
311
315
  single_collection_fetch_url: str
312
316
  #: Query string to be added to the fetch_url to filter for a collection
313
317
  single_collection_fetch_qs: str
314
- #: Mapping for collection metadata returned by the endpoint given in single_collection_fetch_url. If ``ID``
318
+ #: Mapping for collection metadata returned by the endpoint given in single_collection_fetch_url. If ``id``
315
319
  #: is redefined in this mapping, it will replace ``generic_collection_id`` value
316
320
  single_collection_parsable_metadata: dict[str, str]
317
321
 
@@ -331,6 +335,30 @@ class PluginConfig(yaml.YAMLObject):
331
335
  #: :class:`~eodag.plugins.search.base.Search` Key in the json result where the constraints can be found
332
336
  constraints_entry: str
333
337
 
338
+ class CollectionSelector(TypedDict, total=False):
339
+ """Define the criteria to select a collection in :class:`~eodag.config.PluginConfig.DynamicDiscoverQueryables`.
340
+
341
+ The selector matches if the field value starts with the given prefix,
342
+ i.e. it matches if ``parameters[field].startswith(prefix)==True``"""
343
+
344
+ #: Field in the search parameters to match
345
+ field: str
346
+ #: Prefix to match in the field
347
+ prefix: str
348
+
349
+ class DynamicDiscoverQueryables(TypedDict, total=False):
350
+ """Configuration for queryables dynamic discovery.
351
+
352
+ The given configuration for queryables discovery is used if any collection selector
353
+ matches the search parameters.
354
+ """
355
+
356
+ #: List of collection selection criterias
357
+ collection_selector: list[PluginConfig.CollectionSelector]
358
+
359
+ #: Configuration for queryables discovery to use
360
+ discover_queryables: PluginConfig.DiscoverQueryables
361
+
334
362
  class OrderOnResponse(TypedDict):
335
363
  """Configuration for order on-response during download"""
336
364
 
@@ -501,6 +529,12 @@ class PluginConfig(yaml.YAMLObject):
501
529
  version: str
502
530
  #: :class:`~eodag.plugins.apis.ecmwf.EcmwfApi` url of the authentication endpoint
503
531
  auth_endpoint: str
532
+ #: :class:`~eodag.plugins.search.build_search_result.WekeoECMWFSearch`
533
+ #: Configurations for the queryables dynamic auto-discovery.
534
+ #: A configuration is used based on the given selection criterias. The first match is used.
535
+ #: If no match is found, it falls back to standard behaviors (e.g. discovery using
536
+ #: :attr:`~eodag.config.PluginConfig.discover_queryables`).
537
+ dynamic_discover_queryables: list[PluginConfig.DynamicDiscoverQueryables]
504
538
 
505
539
  # download ---------------------------------------------------------------------------------------------------------
506
540
  #: :class:`~eodag.plugins.download.base.Download` Default endpoint url