eodag 3.8.1__py3-none-any.whl → 3.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. eodag/api/core.py +1 -1
  2. eodag/api/product/drivers/generic.py +5 -1
  3. eodag/api/product/metadata_mapping.py +109 -8
  4. eodag/cli.py +36 -4
  5. eodag/config.py +5 -2
  6. eodag/plugins/apis/ecmwf.py +3 -1
  7. eodag/plugins/apis/usgs.py +2 -1
  8. eodag/plugins/authentication/aws_auth.py +228 -37
  9. eodag/plugins/authentication/base.py +12 -2
  10. eodag/plugins/authentication/oauth.py +5 -0
  11. eodag/plugins/base.py +3 -2
  12. eodag/plugins/download/aws.py +44 -285
  13. eodag/plugins/download/base.py +3 -2
  14. eodag/plugins/download/creodias_s3.py +1 -38
  15. eodag/plugins/download/http.py +111 -103
  16. eodag/plugins/download/s3rest.py +3 -1
  17. eodag/plugins/manager.py +2 -1
  18. eodag/plugins/search/__init__.py +2 -1
  19. eodag/plugins/search/base.py +2 -1
  20. eodag/plugins/search/build_search_result.py +2 -2
  21. eodag/plugins/search/creodias_s3.py +9 -1
  22. eodag/plugins/search/qssearch.py +3 -1
  23. eodag/resources/ext_product_types.json +1 -1
  24. eodag/resources/product_types.yml +220 -30
  25. eodag/resources/providers.yml +633 -88
  26. eodag/resources/stac_provider.yml +5 -2
  27. eodag/resources/user_conf_template.yml +0 -5
  28. eodag/rest/core.py +8 -0
  29. eodag/rest/errors.py +9 -0
  30. eodag/rest/server.py +8 -0
  31. eodag/rest/stac.py +8 -0
  32. eodag/rest/utils/__init__.py +2 -4
  33. eodag/rest/utils/rfc3339.py +1 -1
  34. eodag/utils/__init__.py +69 -54
  35. eodag/utils/dates.py +204 -0
  36. eodag/utils/s3.py +187 -168
  37. {eodag-3.8.1.dist-info → eodag-3.9.0.dist-info}/METADATA +4 -3
  38. {eodag-3.8.1.dist-info → eodag-3.9.0.dist-info}/RECORD +42 -42
  39. {eodag-3.8.1.dist-info → eodag-3.9.0.dist-info}/entry_points.txt +1 -1
  40. eodag/utils/rest.py +0 -100
  41. {eodag-3.8.1.dist-info → eodag-3.9.0.dist-info}/WHEEL +0 -0
  42. {eodag-3.8.1.dist-info → eodag-3.9.0.dist-info}/licenses/LICENSE +0 -0
  43. {eodag-3.8.1.dist-info → eodag-3.9.0.dist-info}/top_level.txt +0 -0
@@ -33,6 +33,9 @@ search:
33
33
  auto_discovery: true
34
34
  metadata_pattern: '^[a-zA-Z0-9_:-]+$'
35
35
  search_param: '{{{{"query":{{{{"{metadata}":{{{{"eq":"{{{metadata}}}" }}}} }}}} }}}}'
36
+ search_param_unparsed:
37
+ - filter
38
+ - query
36
39
  metadata_path: '$.properties.*'
37
40
  discover_product_types:
38
41
  fetch_url: '{api_endpoint}/../collections'
@@ -99,7 +102,7 @@ search:
99
102
  - '$.properties."sat:relative_orbit"'
100
103
  orbitDirection:
101
104
  - '{{"query":{{"sat:orbit_state":{{"eq":"{orbitDirection}"}}}}}}'
102
- - '$.properties."sat:orbit_state"'
105
+ - '{$.properties."sat:orbit_state"#to_lower}'
103
106
  cloudCover:
104
107
  - '{{"query":{{"eo:cloud_cover":{{"lte":"{cloudCover}"}}}}}}'
105
108
  - '$.properties."eo:cloud_cover"'
@@ -137,7 +140,7 @@ search:
137
140
  - '$.properties."view:sun_elevation"'
138
141
  polarizationChannels:
139
142
  - '{{"query":{{"sar:polarizations":{{"eq":"{polarizationChannels}"}}}}}}'
140
- - '$.properties."sar:polarizations"'
143
+ - '{$.properties."sar:polarizations"#replace_str(" ","+")}'
141
144
  dopplerFrequency:
142
145
  - '{{"query":{{"sar:frequency_band":{{"eq":"{dopplerFrequency}"}}}}}}'
143
146
  - '$.properties."sar:frequency_band"'
@@ -117,11 +117,6 @@ earth_search:
117
117
  aws_profile:
118
118
  download:
119
119
  output_dir:
120
- earth_search_cog:
121
- priority: # Lower value means lower priority (Default: 0)
122
- search: # Search parameters configuration
123
- download:
124
- output_dir:
125
120
  earth_search_gcs:
126
121
  priority: # Lower value means lower priority (Default: 0)
127
122
  search: # Search parameters configuration
eodag/rest/core.py CHANGED
@@ -21,6 +21,7 @@ import datetime
21
21
  import logging
22
22
  import os
23
23
  import re
24
+ import warnings
24
25
  from typing import TYPE_CHECKING, cast
25
26
  from unittest.mock import Mock
26
27
  from urllib.parse import urlencode
@@ -83,6 +84,13 @@ if TYPE_CHECKING:
83
84
  from starlette.responses import Response
84
85
 
85
86
 
87
+ warnings.warn(
88
+ "The module `eodag.rest.core` is deprecated since v3.9.0 and will be removed in a future version. "
89
+ "The STAC server has moved to https://github.com/CS-SI/stac-fastapi-eodag",
90
+ category=DeprecationWarning,
91
+ stacklevel=2,
92
+ )
93
+
86
94
  eodag_api = eodag.EODataAccessGateway()
87
95
 
88
96
  logger = logging.getLogger("eodag.rest.core")
eodag/rest/errors.py CHANGED
@@ -17,6 +17,7 @@
17
17
  # limitations under the License.
18
18
  import logging
19
19
  import re
20
+ import warnings
20
21
  from typing import Union
21
22
 
22
23
  from fastapi import FastAPI, Request
@@ -53,6 +54,14 @@ EODAG_DEFAULT_STATUS_CODES = {
53
54
  ValidationError: status.HTTP_400_BAD_REQUEST,
54
55
  }
55
56
 
57
+ warnings.warn(
58
+ "The module `eodag.rest.errors` is deprecated since v3.9.0 and will be removed in a future version. "
59
+ "The STAC server has moved to https://github.com/CS-SI/stac-fastapi-eodag",
60
+ category=DeprecationWarning,
61
+ stacklevel=2,
62
+ )
63
+
64
+
56
65
  logger = logging.getLogger("eodag.rest.server")
57
66
 
58
67
 
eodag/rest/server.py CHANGED
@@ -20,6 +20,7 @@ from __future__ import annotations
20
20
  import logging
21
21
  import os
22
22
  import re
23
+ import warnings
23
24
  from contextlib import asynccontextmanager
24
25
  from importlib.metadata import version
25
26
  from json import JSONDecodeError
@@ -67,6 +68,13 @@ if TYPE_CHECKING:
67
68
 
68
69
  from starlette.responses import Response as StarletteResponse
69
70
 
71
+ warnings.warn(
72
+ "The module `eodag.rest.server` is deprecated since v3.9.0 and will be removed in a future version. "
73
+ "The STAC server has moved to https://github.com/CS-SI/stac-fastapi-eodag",
74
+ category=DeprecationWarning,
75
+ stacklevel=2,
76
+ )
77
+
70
78
  logger = logging.getLogger("eodag.rest.server")
71
79
 
72
80
 
eodag/rest/stac.py CHANGED
@@ -19,6 +19,7 @@ from __future__ import annotations
19
19
 
20
20
  import logging
21
21
  import os
22
+ import warnings
22
23
  from collections import defaultdict
23
24
  from datetime import datetime, timezone
24
25
  from typing import TYPE_CHECKING, Any, Optional
@@ -68,6 +69,13 @@ if TYPE_CHECKING:
68
69
  from eodag.api.search_result import SearchResult
69
70
 
70
71
 
72
+ warnings.warn(
73
+ "The module `eodag.rest.stac` is deprecated since v3.9.0 and will be removed in a future version. "
74
+ "The STAC server has moved to https://github.com/CS-SI/stac-fastapi-eodag",
75
+ category=DeprecationWarning,
76
+ stacklevel=2,
77
+ )
78
+
71
79
  logger = logging.getLogger("eodag.rest.stac")
72
80
 
73
81
  # fields not to put in item properties
@@ -34,8 +34,8 @@ from eodag.plugins.crunch.filter_latest_intersect import FilterLatestIntersect
34
34
  from eodag.plugins.crunch.filter_latest_tpl_name import FilterLatestByName
35
35
  from eodag.plugins.crunch.filter_overlap import FilterOverlap
36
36
  from eodag.utils import StreamResponse
37
+ from eodag.utils.dates import get_date, get_datetime
37
38
  from eodag.utils.exceptions import ValidationError
38
- from eodag.utils.rest import get_date, get_datetime
39
39
 
40
40
  if TYPE_CHECKING:
41
41
  from eodag.rest.types.stac_search import SearchPostRequest
@@ -203,7 +203,5 @@ def file_to_stream(
203
203
  filename = os.path.basename(filepath_to_stream)
204
204
  return StreamResponse(
205
205
  content=read_file_chunks_and_delete(open(filepath_to_stream, "rb")),
206
- headers={
207
- "content-disposition": f"attachment; filename={filename}",
208
- },
206
+ filename=filename,
209
207
  )
@@ -18,7 +18,7 @@
18
18
  import datetime
19
19
  from typing import Optional
20
20
 
21
- from eodag.utils.rest import rfc3339_str_to_datetime
21
+ from eodag.utils.dates import rfc3339_str_to_datetime
22
22
 
23
23
 
24
24
  def str_to_interval(
eodag/utils/__init__.py CHANGED
@@ -24,7 +24,6 @@ this package should go here
24
24
  from __future__ import annotations
25
25
 
26
26
  import ast
27
- import datetime
28
27
  import errno
29
28
  import functools
30
29
  import hashlib
@@ -43,8 +42,7 @@ import unicodedata
43
42
  import warnings
44
43
  from collections import defaultdict
45
44
  from copy import deepcopy as copy_deepcopy
46
- from dataclasses import dataclass
47
- from datetime import datetime as dt
45
+ from dataclasses import dataclass, field
48
46
  from email.message import Message
49
47
  from glob import glob
50
48
  from importlib.metadata import metadata
@@ -75,8 +73,6 @@ import orjson
75
73
  import shapefile
76
74
  import shapely.wkt
77
75
  import yaml
78
- from dateutil.parser import isoparse
79
- from dateutil.tz import UTC
80
76
  from jsonpath_ng import jsonpath
81
77
  from jsonpath_ng.ext import parse
82
78
  from jsonpath_ng.jsonpath import Child, Fields, Index, Root, Slice
@@ -414,54 +410,6 @@ def maybe_generator(obj: Any) -> Iterator[Any]:
414
410
  yield obj
415
411
 
416
412
 
417
- def get_timestamp(date_time: str) -> float:
418
- """Return the Unix timestamp of an ISO8601 date/datetime in seconds.
419
-
420
- If the datetime has no offset, it is assumed to be an UTC datetime.
421
-
422
- :param date_time: The datetime string to return as timestamp
423
- :returns: The timestamp corresponding to the ``date_time`` string in seconds
424
- """
425
- dt = isoparse(date_time)
426
- if not dt.tzinfo:
427
- dt = dt.replace(tzinfo=UTC)
428
- return dt.timestamp()
429
-
430
-
431
- def datetime_range(start: dt, end: dt) -> Iterator[dt]:
432
- """Generator function for all dates in-between ``start`` and ``end`` date."""
433
- delta = end - start
434
- for nday in range(delta.days + 1):
435
- yield start + datetime.timedelta(days=nday)
436
-
437
-
438
- def is_range_in_range(valid_range: str, check_range: str) -> bool:
439
- """Check if the check_range is completely within the valid_range.
440
-
441
- This function checks if both the start and end dates of the check_range
442
- are within the start and end dates of the valid_range.
443
-
444
- :param valid_range: The valid date range in the format 'YYYY-MM-DD/YYYY-MM-DD'.
445
- :param check_range: The date range to check in the format 'YYYY-MM-DD/YYYY-MM-DD'.
446
- :returns: True if check_range is within valid_range, otherwise False.
447
- """
448
- if "/" not in valid_range or "/" not in check_range:
449
- return False
450
-
451
- # Split the date ranges into start and end dates
452
- start_valid, end_valid = valid_range.split("/")
453
- start_check, end_check = check_range.split("/")
454
-
455
- # Convert the strings to datetime objects using fromisoformat
456
- start_valid_dt = datetime.datetime.fromisoformat(start_valid)
457
- end_valid_dt = datetime.datetime.fromisoformat(end_valid)
458
- start_check_dt = datetime.datetime.fromisoformat(start_check)
459
- end_check_dt = datetime.datetime.fromisoformat(end_check)
460
-
461
- # Check if check_range is within valid_range
462
- return start_valid_dt <= start_check_dt and end_valid_dt >= end_check_dt
463
-
464
-
465
413
  class DownloadedCallback:
466
414
  """Example class for callback after each download in :meth:`~eodag.api.core.EODataAccessGateway.download_all`"""
467
415
 
@@ -1457,9 +1405,76 @@ class StreamResponse:
1457
1405
  """Represents a streaming response"""
1458
1406
 
1459
1407
  content: Iterable[bytes]
1460
- headers: Optional[Mapping[str, str]] = None
1408
+ _filename: Optional[str] = field(default=None, repr=False, init=False)
1409
+ _size: Optional[int] = field(default=None, repr=False, init=False)
1410
+ headers: dict[str, str] = field(default_factory=dict)
1461
1411
  media_type: Optional[str] = None
1462
1412
  status_code: Optional[int] = None
1413
+ arcname: Optional[str] = None
1414
+
1415
+ def __init__(
1416
+ self,
1417
+ content: Iterable[bytes],
1418
+ filename: Optional[str] = None,
1419
+ size: Optional[int] = None,
1420
+ headers: Optional[Mapping[str, str]] = None,
1421
+ media_type: Optional[str] = None,
1422
+ status_code: Optional[int] = None,
1423
+ arcname: Optional[str] = None,
1424
+ ):
1425
+ self.content = content
1426
+ self.headers = dict(headers) if headers else {}
1427
+ self.media_type = media_type
1428
+ self.status_code = status_code
1429
+ self.arcname = arcname
1430
+ # use property setters to update headers
1431
+ self.filename = filename
1432
+ self.size = size
1433
+
1434
+ # filename handling
1435
+ @property
1436
+ def filename(self) -> Optional[str]:
1437
+ """Get the filename for the streaming response.
1438
+
1439
+ :returns: The filename, or None if not set
1440
+ """
1441
+ return self._filename
1442
+
1443
+ @filename.setter
1444
+ def filename(self, value: Optional[str]) -> None:
1445
+ """Set the filename and update the content-disposition header accordingly.
1446
+
1447
+ :param value: The filename to set, or None to clear it
1448
+ """
1449
+ self._filename = value
1450
+ if value:
1451
+ outputs_filename = os.path.basename(value)
1452
+ self.headers[
1453
+ "content-disposition"
1454
+ ] = f'attachment; filename="{outputs_filename}"'
1455
+ elif "content-disposition" in self.headers:
1456
+ del self.headers["content-disposition"]
1457
+
1458
+ # size handling
1459
+ @property
1460
+ def size(self) -> Optional[int]:
1461
+ """Get the content size for the streaming response.
1462
+
1463
+ :returns: The content size in bytes, or None if not set
1464
+ """
1465
+ return self._size
1466
+
1467
+ @size.setter
1468
+ def size(self, value: Optional[int]) -> None:
1469
+ """Set the content size and update the content-length header accordingly.
1470
+
1471
+ :param value: The content size in bytes, or None to clear it
1472
+ """
1473
+ self._size = value
1474
+ if value is not None:
1475
+ self.headers["content-length"] = str(value)
1476
+ elif "content-length" in self.headers:
1477
+ del self.headers["content-length"]
1463
1478
 
1464
1479
 
1465
1480
  def guess_file_type(file: str) -> Optional[str]:
eodag/utils/dates.py ADDED
@@ -0,0 +1,204 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Copyright 2025, 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
+ """eodag.rest.dates methods that must be importable without eodag[server] installeds"""
19
+
20
+ import datetime
21
+ import re
22
+ from datetime import datetime as dt
23
+ from typing import Any, Iterator, Optional
24
+
25
+ import dateutil.parser
26
+ from dateutil import tz
27
+ from dateutil.parser import isoparse
28
+ from dateutil.tz import UTC
29
+
30
+ from eodag.utils.exceptions import ValidationError
31
+
32
+ RFC3339_PATTERN = (
33
+ r"^(\d{4})-(\d{2})-(\d{2})"
34
+ r"(?:T(\d{2}):(\d{2}):(\d{2})(\.\d+)?"
35
+ r"(Z|([+-])(\d{2}):(\d{2}))?)?$"
36
+ )
37
+
38
+
39
+ def get_timestamp(date_time: str) -> float:
40
+ """Return the Unix timestamp of an ISO8601 date/datetime in seconds.
41
+
42
+ If the datetime has no offset, it is assumed to be an UTC datetime.
43
+
44
+ :param date_time: The datetime string to return as timestamp
45
+ :returns: The timestamp corresponding to the ``date_time`` string in seconds
46
+
47
+ Examples:
48
+ >>> get_timestamp("2023-09-23T12:34:56Z") # doctest: +ELLIPSIS
49
+ 1695472496.0
50
+ >>> get_timestamp("2023-09-23T12:34:56+02:00") # doctest: +ELLIPSIS
51
+ 1695465296.0
52
+ >>> get_timestamp("2023-09-23") # doctest: +ELLIPSIS
53
+ 1695427200.0
54
+ """
55
+ dt = isoparse(date_time)
56
+ if not dt.tzinfo:
57
+ dt = dt.replace(tzinfo=UTC)
58
+ return dt.timestamp()
59
+
60
+
61
+ def datetime_range(start: dt, end: dt) -> Iterator[dt]:
62
+ """Generator function for all dates in-between ``start`` and ``end`` date."""
63
+ delta = end - start
64
+ for nday in range(delta.days + 1):
65
+ yield start + datetime.timedelta(days=nday)
66
+
67
+
68
+ def is_range_in_range(valid_range: str, check_range: str) -> bool:
69
+ """Check if the check_range is completely within the valid_range.
70
+
71
+ This function checks if both the start and end dates of the check_range
72
+ are within the start and end dates of the valid_range.
73
+
74
+ :param valid_range: The valid date range in the format 'YYYY-MM-DD/YYYY-MM-DD'.
75
+ :param check_range: The date range to check in the format 'YYYY-MM-DD/YYYY-MM-DD'.
76
+ :returns: True if check_range is within valid_range, otherwise False.
77
+
78
+ Examples:
79
+ >>> is_range_in_range("2023-01-01/2023-12-31", "2023-03-01/2023-03-31")
80
+ True
81
+ >>> is_range_in_range("2023-01-01/2023-12-31", "2022-12-01/2023-03-31")
82
+ False
83
+ >>> is_range_in_range("2023-01-01/2023-12-31", "2023-11-01/2024-01-01")
84
+ False
85
+ >>> is_range_in_range("2023-01-01/2023-12-31", "invalid-range")
86
+ False
87
+ >>> is_range_in_range("invalid-range", "2023-03-01/2023-03-31")
88
+ False
89
+ """
90
+ if "/" not in valid_range or "/" not in check_range:
91
+ return False
92
+
93
+ # Split the date ranges into start and end dates
94
+ start_valid, end_valid = valid_range.split("/")
95
+ start_check, end_check = check_range.split("/")
96
+
97
+ # Convert the strings to datetime objects using fromisoformat
98
+ start_valid_dt = datetime.datetime.fromisoformat(start_valid)
99
+ end_valid_dt = datetime.datetime.fromisoformat(end_valid)
100
+ start_check_dt = datetime.datetime.fromisoformat(start_check)
101
+ end_check_dt = datetime.datetime.fromisoformat(end_check)
102
+
103
+ # Check if check_range is within valid_range
104
+ return start_valid_dt <= start_check_dt and end_valid_dt >= end_check_dt
105
+
106
+
107
+ def get_datetime(arguments: dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
108
+ """Get start and end dates from a dict containing `/` separated dates in `datetime` item
109
+
110
+ :param arguments: dict containing a single date or `/` separated dates in `datetime` item
111
+ :returns: Start date and end date from datetime string (duplicate value if only one date as input)
112
+
113
+ Examples:
114
+ >>> get_datetime({"datetime": "2023-03-01/2023-03-31"})
115
+ ('2023-03-01T00:00:00', '2023-03-31T00:00:00')
116
+ >>> get_datetime({"datetime": "2023-03-01"})
117
+ ('2023-03-01T00:00:00', '2023-03-01T00:00:00')
118
+ >>> get_datetime({"datetime": "../2023-03-31"})
119
+ (None, '2023-03-31T00:00:00')
120
+ >>> get_datetime({"datetime": "2023-03-01/.."})
121
+ ('2023-03-01T00:00:00', None)
122
+ >>> get_datetime({"dtstart": "2023-03-01", "dtend": "2023-03-31"})
123
+ ('2023-03-01T00:00:00', '2023-03-31T00:00:00')
124
+ >>> get_datetime({})
125
+ (None, None)
126
+ """
127
+ datetime_str = arguments.pop("datetime", None)
128
+
129
+ if datetime_str:
130
+ datetime_split = datetime_str.split("/")
131
+ if len(datetime_split) > 1:
132
+ dtstart = datetime_split[0] if datetime_split[0] != ".." else None
133
+ dtend = datetime_split[1] if datetime_split[1] != ".." else None
134
+ elif len(datetime_split) == 1:
135
+ # same time for start & end if only one is given
136
+ dtstart, dtend = datetime_split[0:1] * 2
137
+ else:
138
+ return None, None
139
+
140
+ return get_date(dtstart), get_date(dtend)
141
+
142
+ else:
143
+ # return already set (dtstart, dtend) or None
144
+ dtstart = get_date(arguments.pop("dtstart", None))
145
+ dtend = get_date(arguments.pop("dtend", None))
146
+ return get_date(dtstart), get_date(dtend)
147
+
148
+
149
+ def get_date(date: Optional[str]) -> Optional[str]:
150
+ """
151
+ Check if the input date can be parsed as a date
152
+
153
+ Examples:
154
+ >>> from eodag.utils.exceptions import ValidationError
155
+ >>> get_date("2023-09-23")
156
+ '2023-09-23T00:00:00'
157
+ >>> get_date(None) is None
158
+ True
159
+ >>> get_date("invalid-date") # doctest: +IGNORE_EXCEPTION_DETAIL
160
+ Traceback (most recent call last):
161
+ ...
162
+ ValidationError
163
+ """
164
+
165
+ if not date:
166
+ return None
167
+ try:
168
+ return (
169
+ dateutil.parser.parse(date)
170
+ .replace(tzinfo=tz.UTC)
171
+ .isoformat()
172
+ .replace("+00:00", "")
173
+ )
174
+ except ValueError as e:
175
+ exc = ValidationError("invalid input date: %s" % e)
176
+ raise exc
177
+
178
+
179
+ def rfc3339_str_to_datetime(s: str) -> datetime.datetime:
180
+ """Convert a string conforming to RFC 3339 to a :class:`datetime.datetime`.
181
+
182
+ :param s: The string to convert to :class:`datetime.datetime`
183
+ :returns: The datetime represented by the ISO8601 (RFC 3339) formatted string
184
+ :raises: :class:`ValidationError`
185
+
186
+ Examples:
187
+ >>> from eodag.utils.exceptions import ValidationError
188
+ >>> rfc3339_str_to_datetime("2023-09-23T12:34:56Z")
189
+ datetime.datetime(2023, 9, 23, 12, 34, 56, tzinfo=datetime.timezone.utc)
190
+
191
+ >>> rfc3339_str_to_datetime("invalid-date") # doctest: +IGNORE_EXCEPTION_DETAIL
192
+ Traceback (most recent call last):
193
+ ...
194
+ ValidationError
195
+ """
196
+ # Uppercase the string
197
+ s = s.upper()
198
+
199
+ # Match against RFC3339 regex.
200
+ result = re.match(RFC3339_PATTERN, s)
201
+ if not result:
202
+ raise ValidationError("Invalid RFC3339 datetime.")
203
+
204
+ return dateutil.parser.isoparse(s).replace(tzinfo=datetime.timezone.utc)