# -*- coding: utf-8 -*-
# Copyright 2025, CS GROUP - France, https://www.csgroup.eu/
#
# This file is part of EODAG project
# https://www.github.com/CS-SI/EODAG
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""eodag.rest.dates methods that must be importable without eodag[server] installeds"""
import calendar
import datetime as dt
import re
from typing import Any, Iterator, Optional, Union
import dateutil.parser
from dateutil import tz
from dateutil.parser import isoparse
from dateutil.tz import UTC
from eodag.utils.exceptions import ValidationError
STAC_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
RFC3339_PATTERN = (
r"^(\d{4})-(\d{2})-(\d{2})"
r"(?:T(\d{2}):(\d{2}):(\d{2})(\.\d+)?"
r"(Z|([+-])(\d{2}):(\d{2}))?)?$"
)
# yyyy-mm-dd
DATE_PATTERN = r"\d{4}-(0[1-9]|1[0-2])-([0-2][0-9]|3[0-1])"
# yyyymmdd
COMPACT_DATE_PATTERN = r"\d{4}(0[1-9]|1[0-2])([0-2][0-9]|3[0-1])"
# yyyy-mm-dd/yyyy-mm-dd, yyyy-mm-dd/to/yyyy-mm-dd
DATE_RANGE_PATTERN = DATE_PATTERN + r"(/to/|/)" + DATE_PATTERN
# yyyymmdd/yyyymmdd, yyyymmdd/to/yyyymmdd
COMPACT_DATE_RANGE_PATTERN = COMPACT_DATE_PATTERN + r"(/to/|/)" + COMPACT_DATE_PATTERN
[docs]
def get_timestamp(date_time: str) -> float:
"""Return the Unix timestamp of an ISO8601 date/datetime in seconds.
If the datetime has no offset, it is assumed to be an UTC datetime.
:param date_time: The datetime string to return as timestamp
:returns: The timestamp corresponding to the ``date_time`` string in seconds
:raises ValueError: If ``date_time`` cannot be parsed as ISO8601
Examples:
>>> get_timestamp("2023-09-23T12:34:56Z") # doctest: +ELLIPSIS
1695472496.0
>>> get_timestamp("2023-09-23T12:34:56+02:00") # doctest: +ELLIPSIS
1695465296.0
>>> get_timestamp("2023-09-23") # doctest: +ELLIPSIS
1695427200.0
"""
dt = isoparse(date_time)
if not dt.tzinfo:
dt = dt.replace(tzinfo=UTC)
return dt.timestamp()
[docs]
def datetime_range(start: dt.datetime, end: dt.datetime) -> Iterator[dt.datetime]:
"""Generator function for all dates in-between ``start`` and ``end`` date.
:param start: Start date
:param end: End date
:returns: Generator of dates
Examples:
>>> from datetime import datetime
>>> dtr = datetime_range(datetime(2020, 12, 31), datetime(2021, 1, 2))
>>> next(dtr)
datetime.datetime(2020, 12, 31, 0, 0)
>>> next(dtr)
datetime.datetime(2021, 1, 1, 0, 0)
>>> next(dtr)
datetime.datetime(2021, 1, 2, 0, 0)
>>> next(dtr)
Traceback (most recent call last):
...
StopIteration
"""
delta = end - start
for nday in range(delta.days + 1):
yield start + dt.timedelta(days=nday)
[docs]
def is_range_in_range(valid_range: str, check_range: str) -> bool:
"""Check if the check_range is completely within the valid_range.
This function checks if both the start and end dates of the check_range
are within the start and end dates of the valid_range.
:param valid_range: The valid date range in the format 'YYYY-MM-DD/YYYY-MM-DD or YYYY-MM-DD/to/YYYY-MM-DD'.
:param check_range: The date range to check in the format 'YYYY-MM-DD/YYYY-MM-DD' or YYYY-MM-DD/to/YYYY-MM-DD.
:returns: True if check_range is within valid_range, otherwise False.
:raises ValueError: If date parts cannot be parsed as ISO8601
Examples:
>>> is_range_in_range("2023-01-01/2023-12-31", "2023-03-01/2023-03-31")
True
>>> is_range_in_range("2023-01-01/2023-12-31", "2022-12-01/2023-03-31")
False
>>> is_range_in_range("2023-01-01/2023-12-31", "2023-11-01/2024-01-01")
False
>>> is_range_in_range("2023-01-01/to/2023-12-31", "2023-11-01/to/2024-01-01")
False
>>> is_range_in_range("2023-01-01/to/2023-12-31", "2023-03-01/2023-03-31")
True
>>> is_range_in_range("2023-01-01/2023-12-31", "invalid-range")
False
>>> is_range_in_range("invalid-range", "2023-03-01/2023-03-31")
False
"""
if "/" not in valid_range or "/" not in check_range:
return False
# Split the date ranges into start and end dates
if "to" in valid_range:
valid_range = valid_range.replace("/to", "")
if "to" in check_range:
check_range = check_range.replace("/to", "")
start_valid, end_valid = valid_range.split("/")
start_check, end_check = check_range.split("/")
# Convert the strings to datetime objects
start_valid_dt = isoparse(start_valid)
end_valid_dt = isoparse(end_valid)
start_check_dt = isoparse(start_check)
end_check_dt = isoparse(end_check)
# Check if check_range is within valid_range
return start_valid_dt <= start_check_dt and end_valid_dt >= end_check_dt
[docs]
def get_datetime(arguments: dict[str, Any]) -> tuple[Optional[str], Optional[str]]:
"""Get start and end dates from a dict containing `/` separated dates in `datetime` item
:param arguments: dict containing a single date or `/` separated dates in `datetime` item
:returns: Start date and end date from datetime string (duplicate value if only one date as input)
:raises ValidationError: If a date string cannot be parsed
Examples:
>>> get_datetime({"datetime": "2023-03-01/2023-03-31"})
('2023-03-01T00:00:00.000Z', '2023-03-31T00:00:00.000Z')
>>> get_datetime({"datetime": "2023-03-01"})
('2023-03-01T00:00:00.000Z', '2023-03-01T00:00:00.000Z')
>>> get_datetime({"datetime": "../2023-03-31"})
(None, '2023-03-31T00:00:00.000Z')
>>> get_datetime({"datetime": "2023-03-01/.."})
('2023-03-01T00:00:00.000Z', None)
>>> get_datetime({"dtstart": "2023-03-01", "dtend": "2023-03-31"})
('2023-03-01T00:00:00.000Z', '2023-03-31T00:00:00.000Z')
>>> get_datetime({})
(None, None)
"""
datetime_str = arguments.pop("datetime", None)
if datetime_str:
datetime_split = datetime_str.split("/")
if len(datetime_split) > 1:
dtstart = datetime_split[0] if datetime_split[0] != ".." else None
dtend = datetime_split[1] if datetime_split[1] != ".." else None
elif len(datetime_split) == 1:
# same time for start & end if only one is given
dtstart, dtend = datetime_split[0:1] * 2
else:
return None, None
return get_date(dtstart), get_date(dtend)
else:
# return already set (dtstart, dtend) or None
dtstart = get_date(arguments.pop("dtstart", None))
dtend = get_date(arguments.pop("dtend", None))
return get_date(dtstart), get_date(dtend)
[docs]
def get_date(date: Optional[str]) -> Optional[str]:
"""
Check if the input date can be parsed as a date
:param date: The date to parse
:returns: The datetime represented with ISO 8601 UTC format
:raises ValidationError: If the date string cannot be parsed
Examples:
>>> from eodag.utils.exceptions import ValidationError
>>> get_date("2023-09-23")
'2023-09-23T00:00:00.000Z'
>>> get_date(None) is None
True
>>> get_date("invalid-date") # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValidationError
"""
if not date:
return None
result = to_iso_utc_string(date)
if result is None:
raise ValidationError("invalid input date: %s" % date)
return result
[docs]
def rfc3339_str_to_datetime(s: str) -> dt.datetime:
"""Convert a string conforming to RFC 3339 to a :class:`datetime.datetime`.
:param s: The string to convert to :class:`datetime.datetime`
:returns: The datetime represented by the ISO8601 (RFC 3339) formatted string
:raises ValidationError: If the string does not conform to RFC 3339
Examples:
>>> from eodag.utils.exceptions import ValidationError
>>> rfc3339_str_to_datetime("2023-09-23T12:34:56Z")
datetime.datetime(2023, 9, 23, 12, 34, 56, tzinfo=datetime.timezone.utc)
>>> rfc3339_str_to_datetime("invalid-date") # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValidationError
"""
# Uppercase the string
s = s.upper()
# Match against RFC3339 regex.
result = re.match(RFC3339_PATTERN, s)
if not result:
raise ValidationError("Invalid RFC3339 datetime.")
return dateutil.parser.isoparse(s).replace(tzinfo=dt.timezone.utc)
[docs]
def get_min_max(
value: Optional[Union[str, list[str]]] = None,
) -> tuple[Optional[str], Optional[str]]:
"""Returns the min and max from a list of strings or the same string if a single string is given.
:param value: a single string or a list of strings
:returns: a tuple with the min and max values
Examples:
>>> get_min_max(["a", "c", "b"])
('a', 'c')
>>> get_min_max(["a"])
('a', 'a')
>>> get_min_max("a")
('a', 'a')
"""
if isinstance(value, list):
sorted_values = sorted(value)
return sorted_values[0], sorted_values[-1]
return value, value
[docs]
def append_time(input_date: dt.date, time: Optional[str] = None) -> dt.datetime:
"""Appends a string-formatted time to a date.
:param input_date: Date to combine with the time
:param time: (optional) time string in format HHMM, HH:MM or HH_MM
:returns: Datetime obtained by appenting the time to the date
Examples:
>>> from eodag.utils.dates import append_time
>>> from datetime import date
>>> append_time(date(2020, 12, 13))
datetime.datetime(2020, 12, 13, 0, 0)
>>> append_time(date(2020, 12, 13), "")
datetime.datetime(2020, 12, 13, 0, 0)
>>> append_time(date(2020, 12, 13), "2400")
datetime.datetime(2020, 12, 13, 0, 0)
>>> append_time(date(2020, 12, 13), "14_31")
datetime.datetime(2020, 12, 13, 14, 31)
"""
if not time:
time = "0000"
time = re.sub(":|_", "", time)
if time == "2400":
time = "0000"
combined_dt = dt.datetime.combine(
input_date, dt.datetime.strptime(time, "%H%M").time()
)
combined_dt.replace(tzinfo=dt.timezone.utc)
return combined_dt
[docs]
def parse_date(
date: str, time: Optional[Union[str, list[str]]] = None
) -> tuple[dt.datetime, dt.datetime]:
"""Parses a date string in formats YYYY-MM-DD, YYYMMDD, solo or in start/end or start/to/end intervals.
:param date: Single or interval date string
:returns: A tuple with the start and end datetime
:raises ValidationError: If a date string cannot be parsed
Examples:
>>> parse_date("2020-12-15")
(datetime.datetime(2020, 12, 15, 0, 0, tzinfo=tzutc()), datetime.datetime(2020, 12, 15, 0, 0, tzinfo=tzutc()))
>>> parse_date("2020-12-15/to/20201230")
(datetime.datetime(2020, 12, 15, 0, 0, tzinfo=tzutc()), datetime.datetime(2020, 12, 30, 0, 0, tzinfo=tzutc()))
"""
if "to" in date:
start_date_str, end_date_str = date.split("/to/")
elif "/" in date:
dates = date.split("/")
start_date_str = dates[0]
end_date_str = dates[-1]
else:
start_date_str = end_date_str = date
# Update YYYYMMDD formatted dates
if re.match(r"^\d{8}$", start_date_str):
start_date_str = (
f"{start_date_str[:4]}-{start_date_str[4:6]}-{start_date_str[6:]}"
)
if re.match(r"^\d{8}$", end_date_str):
end_date_str = f"{end_date_str[:4]}-{end_date_str[4:6]}-{end_date_str[6:]}"
start_date = parse_to_utc(start_date_str)
end_date = parse_to_utc(end_date_str)
if time:
start_t, end_t = get_min_max(time)
start_date = append_time(start_date.date(), start_t)
end_date = append_time(end_date.date(), end_t)
return start_date, end_date
[docs]
def parse_year_month_day(
year: Union[str, list[str]],
month: Optional[Union[str, list[str]]] = None,
day: Optional[Union[str, list[str]]] = None,
time: Optional[Union[str, list[str]]] = None,
) -> tuple[dt.datetime, dt.datetime]:
"""Returns minimum and maximum datetimes from given lists of years, months, days, times.
:param year: List of years or a single one
:param month: (optional) List of months or a single one
:param day: (optional) List of days or a single one
:param time: (optional) List of times or a single one in the format HHMM, HH:MM or HH_MM
:returns: A tuple with the start and end datetime
Examples:
>>> parse_year_month_day(["2020", "2021", "2022"], ["01", "03", "05"], "01", ["0000", "1200"])
(datetime.datetime(2020, 1, 1, 0, 0), datetime.datetime(2022, 5, 1, 12, 0))
"""
def build_date(year, month=None, day=None, time=None) -> dt.datetime:
"""Datetime from default_date with updated year, month, day and time."""
updated_date = dt.datetime(int(year), 1, 1).replace(
month=int(month) if month is not None else 1,
day=int(day) if day is not None else 1,
)
if time is not None:
updated_date = append_time(updated_date.date(), time)
return updated_date
start_y, end_y = get_min_max(year)
start_m, end_m = get_min_max(month)
start_d, end_d = get_min_max(day)
start_t, end_t = get_min_max(time)
start_date = build_date(start_y, start_m, start_d, start_t)
end_date = build_date(end_y, end_m, end_d, end_t)
return start_date, end_date
[docs]
def validate_datetime_param(
value: Optional[Union[str, list[str]]],
param_name: str,
formatters: list[str],
) -> Optional[list[str]]:
"""Validate and collect parameter values matching any of the given datetime formats.
Ensures each value can be parsed by at least one of the ``formatters``
(``datetime.strptime`` patterns), and returns the sorted list of valid values.
:param value: Raw value(s) from search parameters (string or list of strings)
:param param_name: Parameter name (used in error messages)
:param formatters: ``datetime.strptime`` format strings used for validation
:returns: Sorted list of valid values, or ``None`` if ``value`` is ``None``
:raises ValidationError: If none of the values match any formatter
Examples:
>>> validate_datetime_param(["2023", "2024"], "year", ["%Y"])
['2023', '2024']
>>> validate_datetime_param("12:00", "time", ["%H:%M", "%H%M"])
['12:00']
>>> validate_datetime_param(None, "year", ["%Y"]) is None
True
>>> validate_datetime_param("bad", "year", ["%Y"]) # doctest: +ELLIPSIS
Traceback (most recent call last):
...
eodag.utils.exceptions.ValidationError: Malformed parameter "year": ...
"""
if value is None:
return None
if not isinstance(value, list):
value = [value]
buffer = []
has_error = None
for item in value:
for formatter in formatters:
try:
# Prepend a dummy year when format contains %d without %Y
# to avoid ambiguity (deprecated in 3.14, error in 3.15+)
if "%d" in formatter and "%Y" not in formatter:
dt.datetime.strptime(f"2000 {item}", f"%Y {formatter}")
else:
dt.datetime.strptime(item, formatter)
buffer.append(item)
except Exception as e:
has_error = e
if has_error is not None and len(buffer) == 0:
raise ValidationError(
'Malformed parameter "{}": {}'.format(param_name, str(has_error))
)
buffer.sort()
return buffer
[docs]
def time_values_to_hhmm(time_values: list[str]) -> list[str]:
"""Convert time values to 4-digit HHMM format.
Strips non-digit characters (e.g. ``"12:00"`` -> ``"1200"``, ``"06:00"`` -> ``"0600"``),
then right-pads with zeros to handle 2-digit hour-only values (e.g. ``"06"`` -> ``"0600"``).
Deduplicates while preserving order.
:param time_values: List of time strings in various formats
:returns: List of unique time strings in HHMM format
Examples:
>>> time_values_to_hhmm(["12:00", "06:00"])
['1200', '0600']
>>> time_values_to_hhmm(["12:00", "12:00"])
['1200']
>>> time_values_to_hhmm(["06"])
['0600']
"""
buffer: list[str] = []
for time_str in time_values:
time_str = re.sub("[^0-9]+", "", time_str)
time_str = time_str.ljust(4, "0")
if time_str not in buffer:
buffer.append(time_str)
return buffer
[docs]
def ensure_utc(value: dt.datetime) -> dt.datetime:
"""Ensure a datetime is UTC-aware.
If the datetime is naive, it is assumed to be UTC.
If it already has a timezone, it is converted to UTC.
:param value: A datetime object
:returns: A timezone-aware datetime in UTC
Examples:
>>> from datetime import datetime, timezone
>>> ensure_utc(datetime(2020, 1, 1, 12, 0))
datetime.datetime(2020, 1, 1, 12, 0, tzinfo=tzutc())
>>> ensure_utc(datetime(2021, 4, 21, 0, 0, tzinfo=timezone.utc))
datetime.datetime(2021, 4, 21, 0, 0, tzinfo=tzutc())
"""
if not value.tzinfo:
return value.replace(tzinfo=tz.UTC)
return value.astimezone(tz.UTC)
[docs]
def parse_to_utc(raw: str) -> dt.datetime:
"""Parse a date string to a UTC-aware datetime.
Uses ``dateutil.parser.isoparse`` for ISO strings. Falls back to
``dateutil.parser.parse`` for non-ISO formats. Always returns a
timezone-aware datetime in UTC.
:param raw: A date string
:returns: A timezone-aware datetime in UTC
:raises ValidationError: If the string cannot be parsed
Examples:
>>> parse_to_utc("2020-01-01")
datetime.datetime(2020, 1, 1, 0, 0, tzinfo=tzutc())
>>> parse_to_utc("2021-04-21T00:00:00+02:00")
datetime.datetime(2021, 4, 20, 22, 0, tzinfo=tzutc())
>>> parse_to_utc("invalid") # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
ValidationError
"""
try:
parsed = isoparse(raw)
except (ValueError, TypeError):
try:
parsed = dateutil.parser.parse(raw)
except (ValueError, TypeError) as e:
raise ValidationError(f"Cannot parse date: {raw!r}") from e
if not parsed.tzinfo:
parsed = parsed.replace(tzinfo=tz.UTC)
return parsed.astimezone(tz.UTC)
[docs]
def to_iso_utc_string(
raw: Optional[Union[dt.datetime, str]],
) -> Optional[str]:
"""Convert a datetime or date string to an ISO 8601 UTC string with millisecond precision.
:param raw: A datetime object or date string to convert
:returns: ISO 8601 formatted UTC string (``YYYY-MM-DDTHH:MM:SS.sssZ``), or ``None``
Examples:
>>> from datetime import datetime
>>> to_iso_utc_string(datetime(2020, 1, 1, 12, 0))
'2020-01-01T12:00:00.000Z'
>>> to_iso_utc_string("2020-01-01")
'2020-01-01T00:00:00.000Z'
>>> to_iso_utc_string("2021-04-21T00:00:00+02:00")
'2021-04-20T22:00:00.000Z'
>>> to_iso_utc_string(None) is None
True
"""
if raw is None:
return None
try:
utc_dt = ensure_utc(raw) if isinstance(raw, dt.datetime) else parse_to_utc(raw)
return utc_dt.isoformat(timespec="milliseconds").replace("+00:00", "Z")
except (ValidationError, OverflowError):
return None
[docs]
def compute_date_range_from_params(
date: Optional[str] = None,
time: Optional[list[str]] = None,
year: Optional[list[str]] = None,
month: Optional[list[str]] = None,
day: Optional[list[str]] = None,
) -> tuple[Optional[str], Optional[str]]:
"""Compute start/end ISO UTC datetime strings from date parameters.
Handles two modes:
- **date** + optional **time**: parse the date string and apply time bounds
- **year** + optional **month**/**day**/**time**: compute bounds from year/month/day/time ranges
Time values are expected in HHMM format (see :func:`time_values_to_hhmm`).
Returns ``(None, None)`` if neither ``date`` nor ``year`` is provided.
:param date: Date string (single date, or interval with ``/`` or ``/to/``)
:param time: List of normalized time strings in HHMM format
:param year: List of year strings
:param month: List of month strings (zero-padded)
:param day: List of day strings (zero-padded)
:returns: Tuple of (start_datetime, end_datetime) as ISO UTC strings
:raises ValidationError: If a date string cannot be parsed
Examples:
>>> compute_date_range_from_params(date="2020-12-15")
('2020-12-15T00:00:00.000Z', '2020-12-15T00:00:00.000Z')
>>> compute_date_range_from_params(date="2020-12-15", time=["0600", "1800"])
('2020-12-15T06:00:00.000Z', '2020-12-15T18:00:00.000Z')
>>> compute_date_range_from_params(year=["2020", "2021"])
('2020-01-01T00:00:00.000Z', '2021-12-31T23:59:59.000Z')
>>> compute_date_range_from_params(year=["2020"], month=["03"], day=["15"])
('2020-03-15T00:00:00.000Z', '2020-03-15T23:59:59.000Z')
>>> compute_date_range_from_params()
(None, None)
"""
if date is not None:
start, end = parse_date(date, time)
return to_iso_utc_string(start), to_iso_utc_string(end)
if year is not None:
min_year, max_year = year[0], year[-1]
if month:
min_month, max_month = month[0], month[-1]
else:
min_month, max_month = "01", "12"
_, last_day = calendar.monthrange(int(max_year), int(max_month))
min_day = "01"
max_day = str(last_day).zfill(2)
if day:
if min_day <= day[0] <= max_day:
min_day = day[0]
if min_day <= day[-1] <= max_day:
max_day = day[-1]
if time:
min_time = f"{time[0][0:2]}:{time[0][2:4]}:00.000"
max_time = f"{time[-1][0:2]}:{time[-1][2:4]}:00.000"
else:
min_time, max_time = "00:00:00.000", "23:59:59.000"
start_str = f"{min_year}-{min_month}-{min_day}T{min_time}Z"
end_str = f"{max_year}-{max_month}-{max_day}T{max_time}Z"
return start_str, end_str
return None, None