eodag 4.0.0a1__py3-none-any.whl → 4.0.0a3__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/__init__.py +6 -1
- eodag/api/collection.py +354 -0
- eodag/api/core.py +324 -303
- eodag/api/product/_product.py +15 -29
- eodag/api/product/drivers/__init__.py +2 -42
- eodag/api/product/drivers/base.py +0 -11
- eodag/api/product/metadata_mapping.py +34 -5
- eodag/api/search_result.py +144 -9
- eodag/cli.py +18 -15
- eodag/config.py +37 -3
- eodag/plugins/apis/ecmwf.py +16 -4
- eodag/plugins/apis/usgs.py +18 -7
- eodag/plugins/crunch/filter_latest_intersect.py +1 -0
- eodag/plugins/crunch/filter_overlap.py +3 -7
- eodag/plugins/search/__init__.py +3 -0
- eodag/plugins/search/base.py +6 -6
- eodag/plugins/search/build_search_result.py +157 -56
- eodag/plugins/search/cop_marine.py +48 -8
- eodag/plugins/search/csw.py +18 -8
- eodag/plugins/search/qssearch.py +331 -88
- eodag/plugins/search/static_stac_search.py +11 -12
- eodag/resources/collections.yml +610 -348
- eodag/resources/ext_collections.json +1 -1
- eodag/resources/ext_product_types.json +1 -1
- eodag/resources/providers.yml +334 -62
- eodag/resources/stac_provider.yml +4 -2
- eodag/resources/user_conf_template.yml +9 -0
- eodag/types/__init__.py +2 -0
- eodag/types/queryables.py +16 -0
- eodag/utils/__init__.py +47 -2
- eodag/utils/repr.py +2 -0
- {eodag-4.0.0a1.dist-info → eodag-4.0.0a3.dist-info}/METADATA +4 -2
- {eodag-4.0.0a1.dist-info → eodag-4.0.0a3.dist-info}/RECORD +37 -36
- {eodag-4.0.0a1.dist-info → eodag-4.0.0a3.dist-info}/WHEEL +0 -0
- {eodag-4.0.0a1.dist-info → eodag-4.0.0a3.dist-info}/entry_points.txt +0 -0
- {eodag-4.0.0a1.dist-info → eodag-4.0.0a3.dist-info}/licenses/LICENSE +0 -0
- {eodag-4.0.0a1.dist-info → eodag-4.0.0a3.dist-info}/top_level.txt +0 -0
eodag/api/product/_product.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
|
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
|
-
|
|
601
|
-
for
|
|
602
|
-
|
|
603
|
-
|
|
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
|
|
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", "
|
|
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
|
|
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
|
|
eodag/api/search_result.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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(
|
|
340
|
-
eo_product.collection =
|
|
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,
|
|
146
|
-
"platform
|
|
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
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
474
|
-
for prop, value in collection.items():
|
|
475
|
-
if prop != "
|
|
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
|
|
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. ``
|
|
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 ``
|
|
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
|