eodag 2.12.0__py3-none-any.whl → 3.0.0b1__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 (77) hide show
  1. eodag/api/core.py +434 -319
  2. eodag/api/product/__init__.py +5 -1
  3. eodag/api/product/_assets.py +7 -2
  4. eodag/api/product/_product.py +46 -68
  5. eodag/api/product/metadata_mapping.py +181 -66
  6. eodag/api/search_result.py +21 -1
  7. eodag/cli.py +20 -6
  8. eodag/config.py +95 -6
  9. eodag/plugins/apis/base.py +8 -162
  10. eodag/plugins/apis/ecmwf.py +36 -24
  11. eodag/plugins/apis/usgs.py +40 -24
  12. eodag/plugins/authentication/aws_auth.py +2 -2
  13. eodag/plugins/authentication/header.py +31 -6
  14. eodag/plugins/authentication/keycloak.py +13 -84
  15. eodag/plugins/authentication/oauth.py +3 -3
  16. eodag/plugins/authentication/openid_connect.py +256 -46
  17. eodag/plugins/authentication/qsauth.py +3 -0
  18. eodag/plugins/authentication/sas_auth.py +8 -1
  19. eodag/plugins/authentication/token.py +92 -46
  20. eodag/plugins/authentication/token_exchange.py +120 -0
  21. eodag/plugins/download/aws.py +86 -91
  22. eodag/plugins/download/base.py +72 -40
  23. eodag/plugins/download/http.py +607 -264
  24. eodag/plugins/download/s3rest.py +28 -15
  25. eodag/plugins/manager.py +73 -57
  26. eodag/plugins/search/__init__.py +36 -0
  27. eodag/plugins/search/base.py +225 -18
  28. eodag/plugins/search/build_search_result.py +389 -32
  29. eodag/plugins/search/cop_marine.py +378 -0
  30. eodag/plugins/search/creodias_s3.py +15 -14
  31. eodag/plugins/search/csw.py +5 -7
  32. eodag/plugins/search/data_request_search.py +44 -20
  33. eodag/plugins/search/qssearch.py +508 -203
  34. eodag/plugins/search/static_stac_search.py +99 -36
  35. eodag/resources/constraints/climate-dt.json +13 -0
  36. eodag/resources/constraints/extremes-dt.json +8 -0
  37. eodag/resources/ext_product_types.json +1 -1
  38. eodag/resources/product_types.yml +1897 -34
  39. eodag/resources/providers.yml +3539 -3277
  40. eodag/resources/stac.yml +48 -54
  41. eodag/resources/stac_api.yml +71 -25
  42. eodag/resources/stac_provider.yml +5 -0
  43. eodag/resources/user_conf_template.yml +51 -3
  44. eodag/rest/__init__.py +6 -0
  45. eodag/rest/cache.py +70 -0
  46. eodag/rest/config.py +68 -0
  47. eodag/rest/constants.py +27 -0
  48. eodag/rest/core.py +757 -0
  49. eodag/rest/server.py +397 -258
  50. eodag/rest/stac.py +438 -307
  51. eodag/rest/types/collections_search.py +44 -0
  52. eodag/rest/types/eodag_search.py +232 -43
  53. eodag/rest/types/{stac_queryables.py → queryables.py} +81 -43
  54. eodag/rest/types/stac_search.py +277 -0
  55. eodag/rest/utils/__init__.py +216 -0
  56. eodag/rest/utils/cql_evaluate.py +119 -0
  57. eodag/rest/utils/rfc3339.py +65 -0
  58. eodag/types/__init__.py +99 -9
  59. eodag/types/bbox.py +15 -14
  60. eodag/types/download_args.py +31 -0
  61. eodag/types/search_args.py +58 -7
  62. eodag/types/whoosh.py +81 -0
  63. eodag/utils/__init__.py +72 -9
  64. eodag/utils/constraints.py +37 -37
  65. eodag/utils/exceptions.py +23 -17
  66. eodag/utils/requests.py +138 -0
  67. eodag/utils/rest.py +104 -0
  68. eodag/utils/stac_reader.py +100 -16
  69. {eodag-2.12.0.dist-info → eodag-3.0.0b1.dist-info}/METADATA +64 -44
  70. eodag-3.0.0b1.dist-info/RECORD +109 -0
  71. {eodag-2.12.0.dist-info → eodag-3.0.0b1.dist-info}/WHEEL +1 -1
  72. {eodag-2.12.0.dist-info → eodag-3.0.0b1.dist-info}/entry_points.txt +6 -5
  73. eodag/plugins/apis/cds.py +0 -540
  74. eodag/rest/utils.py +0 -1133
  75. eodag-2.12.0.dist-info/RECORD +0 -94
  76. {eodag-2.12.0.dist-info → eodag-3.0.0b1.dist-info}/LICENSE +0 -0
  77. {eodag-2.12.0.dist-info → eodag-3.0.0b1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,277 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Copyright 2023, CS GROUP - France, https://www.csgroup.eu/
3
+ #
4
+ # This file is part of EODAG project
5
+ # https://www.github.com/CS-SI/EODAG
6
+ #
7
+ # Licensed under the Apache License, Version 2.0 (the "License");
8
+ # you may not use this file except in compliance with the License.
9
+ # You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing, software
14
+ # distributed under the License is distributed on an "AS IS" BASIS,
15
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ # See the License for the specific language governing permissions and
17
+ # limitations under the License.
18
+ """Model describing a STAC search POST request"""
19
+ from __future__ import annotations
20
+
21
+ from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Union
22
+
23
+ import geojson
24
+ from pydantic import (
25
+ BaseModel,
26
+ ConfigDict,
27
+ Field,
28
+ PositiveInt,
29
+ StringConstraints,
30
+ field_serializer,
31
+ field_validator,
32
+ model_validator,
33
+ )
34
+ from shapely.geometry import (
35
+ GeometryCollection,
36
+ LineString,
37
+ MultiLineString,
38
+ MultiPoint,
39
+ MultiPolygon,
40
+ Point,
41
+ Polygon,
42
+ shape,
43
+ )
44
+ from shapely.geometry.base import GEOMETRY_TYPES, BaseGeometry
45
+ from typing_extensions import Annotated
46
+
47
+ from eodag.rest.utils.rfc3339 import rfc3339_str_to_datetime, str_to_interval
48
+ from eodag.utils.exceptions import ValidationError
49
+
50
+ if TYPE_CHECKING:
51
+ try:
52
+ from typing import Self
53
+ except ImportError:
54
+ from _typeshed import Self
55
+
56
+ NumType = Union[float, int]
57
+
58
+ BBox = Union[
59
+ Tuple[NumType, NumType, NumType, NumType],
60
+ Tuple[NumType, NumType, NumType, NumType, NumType, NumType],
61
+ ]
62
+
63
+ Geometry = Union[
64
+ Point,
65
+ MultiPoint,
66
+ LineString,
67
+ MultiLineString,
68
+ Polygon,
69
+ MultiPolygon,
70
+ GeometryCollection,
71
+ ]
72
+
73
+
74
+ Direction = Annotated[Literal["asc", "desc"], StringConstraints(min_length=1)]
75
+
76
+
77
+ class Sortby(BaseModel):
78
+ """
79
+ A class representing a parameter with which we want to sort results and its sorting order in a
80
+ POST search
81
+
82
+ :param field: The name of the parameter with which we want to sort results
83
+ :type field: str
84
+ :param direction: The sorting order of the parameter
85
+ :type direction: str
86
+ """
87
+
88
+ __pydantic_config__ = ConfigDict(extra="forbid")
89
+
90
+ field: Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)]
91
+ direction: Direction
92
+
93
+
94
+ class SearchPostRequest(BaseModel):
95
+ """
96
+ class which describes the body of a search request
97
+
98
+ Overrides the validation for datetime and spatial filter from the base request model.
99
+ """
100
+
101
+ model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True)
102
+
103
+ provider: Optional[str] = None
104
+ collections: Optional[List[str]] = None
105
+ ids: Optional[List[str]] = None
106
+ bbox: Optional[BBox] = None
107
+ intersects: Optional[Geometry] = None
108
+ datetime: Optional[str] = None
109
+ limit: Optional[PositiveInt] = Field( # type: ignore
110
+ default=None, description="Maximum number of items per page."
111
+ )
112
+ page: Optional[PositiveInt] = Field( # type: ignore
113
+ default=None, description="Page number, must be a positive integer."
114
+ )
115
+ query: Optional[Dict[str, Any]] = None
116
+ filter: Optional[Dict[str, Any]] = None
117
+ filter_lang: Optional[str] = Field(
118
+ default=None,
119
+ alias="filter-lang",
120
+ description="The language used for filtering.",
121
+ validate_default=True,
122
+ )
123
+ sortby: Optional[List[Sortby]] = None
124
+ crunch: Optional[str] = None
125
+
126
+ @field_serializer("intersects")
127
+ def serialize_intersects(
128
+ self, intersects: Optional[Geometry]
129
+ ) -> Optional[Dict[str, Any]]:
130
+ """Serialize intersects from shapely to a proper dict"""
131
+ if intersects:
132
+ return geojson.loads(geojson.dumps(intersects)) # type: ignore
133
+ return None
134
+
135
+ @model_validator(mode="after")
136
+ def check_filter_lang(self) -> Self:
137
+ """Verify filter-lang has correct value"""
138
+ if not self.filter_lang and self.filter:
139
+ self.filter_lang = "cql2-json"
140
+ if self.filter_lang and not self.filter:
141
+ raise ValueError("filter-lang is set but filter is missing")
142
+ if self.filter_lang != "cql2-json" and self.filter:
143
+ raise ValueError('Only filter language "cql2-json" is accepted')
144
+ return self
145
+
146
+ @model_validator(mode="before")
147
+ @classmethod
148
+ def only_one_spatial(cls, values: Dict[str, Any]) -> Dict[str, Any]:
149
+ """Check bbox and intersects are not both supplied."""
150
+ if "intersects" in values and "bbox" in values:
151
+ raise ValueError("intersects and bbox parameters are mutually exclusive")
152
+ return values
153
+
154
+ @property
155
+ def start_date(self) -> Optional[str]:
156
+ """Extract the start date from the datetime string."""
157
+ start = str_to_interval(self.datetime)[0]
158
+ return start.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" if start else None
159
+
160
+ @property
161
+ def end_date(self) -> Optional[str]:
162
+ """Extract the end date from the datetime string."""
163
+ end = str_to_interval(self.datetime)[1]
164
+ return end.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" if end else None
165
+
166
+ @field_validator("ids", "collections", mode="before")
167
+ @classmethod
168
+ def str_to_str_list(cls, v: Union[str, List[str]]) -> List[str]:
169
+ """Convert ids and collections strings to list of strings"""
170
+ if isinstance(v, str):
171
+ return [i.strip() for i in v.split(",")]
172
+ return v
173
+
174
+ @field_validator("intersects", mode="before")
175
+ @classmethod
176
+ def validate_intersects(cls, v: Union[Dict[str, Any], Geometry]) -> Geometry:
177
+ """Verify format of intersects"""
178
+ if isinstance(v, BaseGeometry):
179
+ return v
180
+
181
+ if isinstance(v, dict) and v.get("type") in GEOMETRY_TYPES: # type: ignore
182
+ return shape(v)
183
+
184
+ raise ValueError("Not a valid geometry")
185
+
186
+ @field_validator("bbox")
187
+ @classmethod
188
+ def validate_bbox(cls, v: BBox) -> BBox:
189
+ """Check order of supplied bbox coordinates."""
190
+ # Validate order
191
+ if len(v) == 4:
192
+ xmin, ymin, xmax, ymax = v
193
+ else:
194
+ xmin, ymin, min_elev, xmax, ymax, max_elev = v
195
+ if max_elev < min_elev:
196
+ raise ValueError(
197
+ "Maximum elevation must greater than minimum elevation"
198
+ )
199
+
200
+ if xmax < xmin:
201
+ raise ValueError("Maximum longitude must be greater than minimum longitude")
202
+
203
+ if ymax < ymin:
204
+ raise ValueError("Maximum longitude must be greater than minimum longitude")
205
+
206
+ # Validate against WGS84
207
+ if xmin < -180 or ymin < -90 or xmax > 180 or ymax > 90:
208
+ raise ValueError("Bounding box must be within (-180, -90, 180, 90)")
209
+
210
+ return v
211
+
212
+ @field_validator("datetime")
213
+ @classmethod
214
+ def validate_datetime(cls, v: str) -> str:
215
+ """Validate datetime."""
216
+ if "/" in v:
217
+ values = v.split("/")
218
+ else:
219
+ # Single date is interpreted as end date
220
+ values = ["..", v]
221
+
222
+ dates: List[str] = []
223
+ for value in values:
224
+ if value == ".." or value == "":
225
+ dates.append("..")
226
+ continue
227
+
228
+ try:
229
+ dates.append(
230
+ rfc3339_str_to_datetime(value).strftime("%Y-%m-%dT%H:%M:%SZ")
231
+ )
232
+ except ValidationError as e:
233
+ raise ValueError(e)
234
+
235
+ if dates[0] == ".." and dates[1] == "..":
236
+ raise ValueError(
237
+ "Invalid datetime range, both ends of range may not be open"
238
+ )
239
+
240
+ if ".." not in dates and dates[0] > dates[1]:
241
+ raise ValueError(
242
+ "Invalid datetime range, must match format (begin_date, end_date)"
243
+ )
244
+
245
+ return v
246
+
247
+ @property
248
+ def spatial_filter(self) -> Optional[Geometry]:
249
+ """Return a geojson-pydantic object representing the spatial filter for the search
250
+ request.
251
+
252
+ Check for both because the ``bbox`` and ``intersects`` parameters are
253
+ mutually exclusive.
254
+ """
255
+ if self.bbox:
256
+ return Polygon.from_bounds(*self.bbox) # type: ignore
257
+
258
+ if self.intersects:
259
+ return self.intersects
260
+ return None
261
+
262
+
263
+ def sortby2list(
264
+ v: Optional[str],
265
+ ) -> Optional[List[Sortby]]:
266
+ """
267
+ Convert sortby filter parameter GET syntax to POST syntax
268
+ """
269
+ if not v:
270
+ return None
271
+ sortby: List[Sortby] = []
272
+ for sortby_param in v.split(","):
273
+ sortby_param = sortby_param.strip()
274
+ direction: Direction = "desc" if sortby_param.startswith("-") else "asc"
275
+ field = sortby_param.lstrip("+-")
276
+ sortby.append(Sortby(field=field, direction=direction))
277
+ return sortby
@@ -0,0 +1,216 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Copyright 2023, CS Systemes d'Information, https://www.csgroup.eu/
3
+ #
4
+ # This file is part of EODAG project
5
+ # https://www.github.com/CS-SI/EODAG
6
+ #
7
+ # Licensed under the Apache License, Version 2.0 (the "License");
8
+ # you may not use this file except in compliance with the License.
9
+ # You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing, software
14
+ # distributed under the License is distributed on an "AS IS" BASIS,
15
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ # See the License for the specific language governing permissions and
17
+ # limitations under the License.
18
+ """EODAG REST utils"""
19
+ from __future__ import annotations
20
+
21
+ import glob
22
+ import logging
23
+ import os
24
+ from io import BufferedReader
25
+ from shutil import make_archive, rmtree
26
+ from typing import (
27
+ TYPE_CHECKING,
28
+ Any,
29
+ Callable,
30
+ Dict,
31
+ Iterator,
32
+ List,
33
+ NamedTuple,
34
+ Optional,
35
+ Union,
36
+ )
37
+ from urllib.parse import unquote_plus, urlencode
38
+
39
+ import orjson
40
+ from fastapi import Request
41
+ from pydantic import ValidationError as pydanticValidationError
42
+
43
+ from eodag.plugins.crunch.filter_latest_intersect import FilterLatestIntersect
44
+ from eodag.plugins.crunch.filter_latest_tpl_name import FilterLatestByName
45
+ from eodag.plugins.crunch.filter_overlap import FilterOverlap
46
+ from eodag.utils import StreamResponse
47
+ from eodag.utils.exceptions import ValidationError
48
+ from eodag.utils.rest import get_date, get_datetime
49
+
50
+ if TYPE_CHECKING:
51
+ from eodag.rest.types.stac_search import SearchPostRequest
52
+
53
+ # exportable content
54
+ __all__ = ["get_date", "get_datetime"]
55
+
56
+ logger = logging.getLogger("eodag.rest.utils")
57
+
58
+
59
+ class Cruncher(NamedTuple):
60
+ """Type hinted Cruncher namedTuple"""
61
+
62
+ clazz: Callable[..., Any]
63
+ config_params: List[str]
64
+
65
+
66
+ crunchers = {
67
+ "latestIntersect": Cruncher(FilterLatestIntersect, []),
68
+ "latestByName": Cruncher(FilterLatestByName, ["name_pattern"]),
69
+ "overlap": Cruncher(FilterOverlap, ["minimum_overlap"]),
70
+ }
71
+
72
+
73
+ def format_pydantic_error(e: pydanticValidationError) -> str:
74
+ """Format Pydantic ValidationError
75
+
76
+ :param e: A Pydantic ValidationError object
77
+ :tyype e: pydanticValidationError
78
+ """
79
+ error_header = f"{e.error_count()} error(s). "
80
+
81
+ error_messages = [
82
+ f'{err["loc"][0]}: {err["msg"]}' if err["loc"] else err["msg"]
83
+ for err in e.errors()
84
+ ]
85
+ return error_header + "; ".join(set(error_messages))
86
+
87
+
88
+ def is_dict_str_any(var: Any) -> bool:
89
+ """Verify whether the variable is of type dict[str, Any]"""
90
+ if isinstance(var, Dict):
91
+ return all(isinstance(k, str) for k in var.keys()) # type: ignore
92
+ return False
93
+
94
+
95
+ def str2list(v: Optional[str]) -> Optional[List[str]]:
96
+ """Convert string to list base on , delimiter."""
97
+ if v:
98
+ return v.split(",")
99
+ return None
100
+
101
+
102
+ def str2json(k: str, v: Optional[str] = None) -> Optional[Dict[str, Any]]:
103
+ """decoding a URL parameter and then parsing it as JSON."""
104
+ if not v:
105
+ return None
106
+ try:
107
+ return orjson.loads(unquote_plus(v))
108
+ except orjson.JSONDecodeError as e:
109
+ raise ValidationError(f"{k}: Incorrect JSON object") from e
110
+
111
+
112
+ def flatten_list(nested_list: Union[Any, List[Any]]) -> List[Any]:
113
+ """Flatten a nested list structure into a single list."""
114
+ if not isinstance(nested_list, list):
115
+ return [nested_list]
116
+ else:
117
+ flattened: List[Any] = []
118
+ for element in nested_list:
119
+ flattened.extend(flatten_list(element))
120
+ return flattened
121
+
122
+
123
+ def list_to_str_list(input_list: List[Any]) -> List[str]:
124
+ """Attempt to convert a list of any type to a list of strings."""
125
+ try:
126
+ # Try to convert each element to a string
127
+ return [str(element) for element in input_list]
128
+ except Exception as e:
129
+ # Raise an exception if any element cannot be converted
130
+ raise TypeError(f"Failed to convert to List[str]: {e}") from e
131
+
132
+
133
+ def get_next_link(
134
+ request: Request,
135
+ search_request: SearchPostRequest,
136
+ total_results: Optional[int],
137
+ items_per_page: int,
138
+ ) -> Optional[Dict[str, Any]]:
139
+ """Generate next link URL and body"""
140
+ body = search_request.model_dump(exclude_none=True)
141
+ if "bbox" in body:
142
+ # bbox is tuple
143
+ body["bbox"] = list(body["bbox"])
144
+
145
+ params = dict(request.query_params)
146
+
147
+ page = int(body.get("page", 0) or params.get("page", 0)) or 1
148
+
149
+ if total_results is None or items_per_page * page >= total_results:
150
+ return None
151
+
152
+ url = str(request.state.url)
153
+ if request.method == "POST":
154
+ body["page"] = page + 1
155
+ else:
156
+ params["page"] = str(page + 1)
157
+ url += f"?{urlencode(params)}"
158
+
159
+ next: Dict[str, Any] = {
160
+ "rel": "next",
161
+ "href": url,
162
+ "title": "Next page",
163
+ "method": request.method,
164
+ "type": "application/geo+json",
165
+ }
166
+ if request.method == "POST":
167
+ next["body"] = body
168
+ return next
169
+
170
+
171
+ def read_file_chunks_and_delete(
172
+ opened_file: BufferedReader, chunk_size: int = 64 * 1024
173
+ ) -> Iterator[bytes]:
174
+ """Yield file chunks and delete file when finished."""
175
+ while True:
176
+ data = opened_file.read(chunk_size)
177
+ if not data:
178
+ opened_file.close()
179
+ os.remove(opened_file.name)
180
+ logger.debug("%s deleted after streaming complete", opened_file.name)
181
+ break
182
+ yield data
183
+ yield data
184
+
185
+
186
+ def file_to_stream(
187
+ file_path: str,
188
+ ) -> StreamResponse:
189
+ """Break a file into chunck and return it as a byte stream"""
190
+ if os.path.isdir(file_path):
191
+ # do not zip if dir contains only one file
192
+ all_filenames = [
193
+ f
194
+ for f in glob.glob(os.path.join(file_path, "**", "*"), recursive=True)
195
+ if os.path.isfile(f)
196
+ ]
197
+ if len(all_filenames) == 1:
198
+ filepath_to_stream = all_filenames[0]
199
+ else:
200
+ filepath_to_stream = f"{file_path}.zip"
201
+ logger.debug(
202
+ "Building archive for downloaded product path %s",
203
+ filepath_to_stream,
204
+ )
205
+ make_archive(file_path, "zip", file_path)
206
+ rmtree(file_path)
207
+ else:
208
+ filepath_to_stream = file_path
209
+
210
+ filename = os.path.basename(filepath_to_stream)
211
+ return StreamResponse(
212
+ content=read_file_chunks_and_delete(open(filepath_to_stream, "rb")),
213
+ headers={
214
+ "content-disposition": f"attachment; filename={filename}",
215
+ },
216
+ )
@@ -0,0 +1,119 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Copyright 2023, CS Systemes d'Information, https://www.csgroup.eu/
3
+ #
4
+ # This file is part of EODAG project
5
+ # https://www.github.com/CS-SI/EODAG
6
+ #
7
+ # Licensed under the Apache License, Version 2.0 (the "License");
8
+ # you may not use this file except in compliance with the License.
9
+ # You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing, software
14
+ # distributed under the License is distributed on an "AS IS" BASIS,
15
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ # See the License for the specific language governing permissions and
17
+ # limitations under the License.
18
+ from datetime import datetime as dt
19
+ from typing import Any, Dict, List, Optional, Tuple, Union
20
+
21
+ from pygeofilter import ast
22
+ from pygeofilter.backends.evaluator import Evaluator, handle
23
+ from pygeofilter.values import Geometry, Interval
24
+
25
+ simpleNode = Union[ast.Attribute, str, int, complex, float, List[Any], Tuple[Any, ...]]
26
+
27
+
28
+ class EodagEvaluator(Evaluator):
29
+ """
30
+ Evaluate a cql2 json expression and transform it to a STAC args object
31
+ """
32
+
33
+ @handle(ast.Attribute, str, int, complex, float, list, tuple)
34
+ def attribute(self, node: simpleNode, *_) -> simpleNode:
35
+ """handle attribute and literal"""
36
+ return node
37
+
38
+ @handle(Geometry)
39
+ def spatial(self, node: Geometry) -> Dict[str, Any]:
40
+ """handle geometry"""
41
+ return node.geometry
42
+
43
+ @handle(dt)
44
+ def temporal(self, node: dt) -> str:
45
+ """handle datetime"""
46
+ return node.strftime("%Y-%m-%dT%H:%M:%SZ")
47
+
48
+ @handle(Interval)
49
+ def interval(self, _, *interval: Any) -> List[Any]:
50
+ """handle datetime interval"""
51
+ return list(interval)
52
+
53
+ @handle(
54
+ ast.GeometryIntersects,
55
+ ast.Equal,
56
+ ast.LessEqual,
57
+ ast.GreaterEqual,
58
+ ast.TimeOverlaps,
59
+ ast.In,
60
+ )
61
+ def predicate(
62
+ self, node: ast.Predicate, lhs: Any, rhs: Any
63
+ ) -> Optional[Dict[str, Any]]:
64
+ """
65
+ Handle predicates
66
+ Verify the property is first attribute in each predicate
67
+ """
68
+ if not isinstance(lhs, ast.Attribute):
69
+ raise ValueError(
70
+ f'invalid cql syntax, first argument in "{node.op.value}" must be a property'
71
+ )
72
+
73
+ if isinstance(node, ast.Equal) and not isinstance(
74
+ rhs, (int, float, complex, str)
75
+ ):
76
+ raise ValueError(
77
+ f'second argument in property "{lhs.name}" must be a string or a numeric value'
78
+ )
79
+
80
+ if isinstance(node, ast.GeometryIntersects) and not lhs.name == "geometry":
81
+ raise ValueError(
82
+ f'operator {node.op.value} is not supported for property "{lhs.name}"'
83
+ )
84
+
85
+ if isinstance(node, (ast.Equal, ast.GeometryIntersects)):
86
+ return {lhs.name: rhs}
87
+
88
+ if isinstance(node, ast.LessEqual):
89
+ if not isinstance(node.rhs, dt):
90
+ raise ValueError(
91
+ f'operator "<=" is not supported for property "{lhs.name}"'
92
+ )
93
+ return {"end_datetime": rhs}
94
+
95
+ if isinstance(node, ast.GreaterEqual):
96
+ if not isinstance(node.rhs, dt):
97
+ raise ValueError(
98
+ f'operator ">=" is not supported for property "{lhs.name}"'
99
+ )
100
+ return {"start_datetime": rhs}
101
+
102
+ if isinstance(node, ast.TimeOverlaps):
103
+ return {"start_datetime": rhs[0], "end_datetime": rhs[1]}
104
+
105
+ return None
106
+
107
+ @handle(ast.In)
108
+ def contains(self, node: ast.In, lhs: Any, *rhs: Any):
109
+ """handle in keyword"""
110
+ if not isinstance(node.sub_nodes, list): # type: ignore
111
+ raise ValueError(
112
+ f'property "{lhs.name}" expects a value in list format with operator "in"'
113
+ )
114
+ return {lhs.name: list(rhs)}
115
+
116
+ @handle(ast.And)
117
+ def combination(self, _, lhs: Dict[str, str], rhs: Dict[str, str]):
118
+ """handle combinations"""
119
+ return {**lhs, **rhs}
@@ -0,0 +1,65 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Copyright 2023, CS Systemes d'Information, https://www.csgroup.eu/
3
+ #
4
+ # This file is part of EODAG project
5
+ # https://www.github.com/CS-SI/EODAG
6
+ #
7
+ # Licensed under the Apache License, Version 2.0 (the "License");
8
+ # you may not use this file except in compliance with the License.
9
+ # You may obtain a copy of the License at
10
+ #
11
+ # http://www.apache.org/licenses/LICENSE-2.0
12
+ #
13
+ # Unless required by applicable law or agreed to in writing, software
14
+ # distributed under the License is distributed on an "AS IS" BASIS,
15
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ # See the License for the specific language governing permissions and
17
+ # limitations under the License.
18
+ import datetime
19
+ from typing import Optional, Tuple
20
+
21
+ from eodag.utils.rest import rfc3339_str_to_datetime
22
+
23
+
24
+ def str_to_interval(
25
+ interval: Optional[str],
26
+ ) -> Tuple[Optional[datetime.datetime], Optional[datetime.datetime]]:
27
+ """Extract a tuple of datetimes from an interval string.
28
+
29
+ Interval strings are defined by
30
+ OGC API - Features Part 1 for the datetime query parameter value. These follow the
31
+ form '1985-04-12T23:20:50.52Z/1986-04-12T23:20:50.52Z', and allow either the start
32
+ or end (but not both) to be open-ended with '..' or ''.
33
+
34
+ :param interval: The interval string to convert to a :class:`datetime.datetime`
35
+ tuple.
36
+ :type interval: str
37
+
38
+ :raises: :class:`ValueError`
39
+ """
40
+ if not interval:
41
+ return (None, None)
42
+
43
+ if "/" not in interval:
44
+ date = rfc3339_str_to_datetime(interval)
45
+ return (date, date)
46
+
47
+ values = interval.split("/")
48
+ if len(values) != 2:
49
+ raise ValueError(
50
+ f"Interval string '{interval}' contains more than one forward slash."
51
+ )
52
+
53
+ start = None
54
+ end = None
55
+ if values[0] not in ["..", ""]:
56
+ start = rfc3339_str_to_datetime(values[0])
57
+ if values[1] not in ["..", ""]:
58
+ end = rfc3339_str_to_datetime(values[1])
59
+
60
+ if start is None and end is None:
61
+ raise ValueError("Double open-ended intervals are not allowed.")
62
+ if start is not None and end is not None and start > end:
63
+ raise ValueError("Start datetime cannot be before end datetime.")
64
+ else:
65
+ return start, end