Source code for eodag.utils.dates

# -*- 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 format_date(date: dt.datetime) -> str: """Format a ``datetime`` with the format 'YYYY-MM-DD'. :param date: Datetime to format :returns: Date string in the format 'YYYY-MM-DD' Examples: >>> from datetime import datetime >>> format_date(datetime(2020, 12, 2)) '2020-12-02' >>> format_date(datetime(2020, 12, 2, 11, 22, 33)) '2020-12-02' """ return date.isoformat()[:10]
[docs] def format_date_range(start: dt.datetime, end: dt.datetime) -> str: """Format a range with the format 'YYYY-MM-DD/YYYY-MM-DD'. :param start: Start datetime :param end: End datetime :returns: Date range in the format 'YYYY-MM-DD/YYYY-MM-DD' Examples: >>> from datetime import datetime >>> format_date_range(datetime(2020, 12, 2, 11, 22, 33), datetime(2020, 12, 31)) '2020-12-02/2020-12-31' """ return f"{format_date(start)}/{format_date(end)}"
[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