Source code for eodag.types.queryables

# -*- 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.
from __future__ import annotations

import re
from collections import UserDict
from typing import TYPE_CHECKING, Annotated, Any, Optional, Union, cast

from annotated_types import Lt
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
from pydantic.fields import FieldInfo
from pydantic.types import PositiveInt
from pydantic_core import PydanticUndefined
from shapely.geometry.base import BaseGeometry
from typing_extensions import get_args

from eodag.types import (
    BaseModelCustomJsonSchema,
    annotated_dict_to_model,
    model_fields_to_annotated,
)
from eodag.types.stac_extensions import STAC_EXTENSIONS
from eodag.types.stac_metadata import CommonStacMetadata, create_stac_metadata_model
from eodag.utils.dates import (
    COMPACT_DATE_PATTERN,
    COMPACT_DATE_RANGE_PATTERN,
    DATE_PATTERN,
    DATE_RANGE_PATTERN,
    datetime_range,
    format_date,
    format_date_range,
    is_range_in_range,
    parse_date,
)
from eodag.utils.exceptions import ValidationError as EodagValidationError
from eodag.utils.repr import remove_class_repr, shorter_type_repr

if TYPE_CHECKING:
    from eodag.types.stac_extensions import BaseStacExtension

Percentage = Annotated[PositiveInt, Lt(100)]


[docs] class CommonQueryables(BaseModelCustomJsonSchema): """A class representing search common queryable properties.""" collection: Annotated[str, Field()]
[docs] @classmethod def get_queryable_from_alias(cls, value: str) -> str: """Get queryable parameter from alias >>> CommonQueryables.get_queryable_from_alias('collection') 'collection' """ for name, field_info in cls.model_fields.items(): if field_info.alias: if isinstance(field_info.alias, AliasChoices): aliases = field_info.alias.choices if value in aliases: return name else: if value == field_info.alias: return name return value
[docs] @classmethod def get_with_default( cls, field: str, default: Optional[Any] ) -> Annotated[Any, FieldInfo]: """Get field and set default value.""" annotated_fields = model_fields_to_annotated(cls.model_fields) f = annotated_fields[field] if default is None: return f f.__metadata__[0].default = default return f
[docs] @classmethod def from_stac_models( cls, extensions: list[BaseStacExtension] = STAC_EXTENSIONS, base_model: type[BaseModel] = CommonStacMetadata, ) -> type[Queryables]: """Creates Queryables from STAC models. :param extensions: list of STAC extensions to include in the model :param base_model: base STAC model to use :return: Queryables model """ return cast( type[Queryables], create_stac_metadata_model( base_models=[cls, base_model], extensions=extensions, class_name="Queryables", ), )
[docs] class Queryables(CommonQueryables): """A class representing all search queryable properties. Parameters default value is set to ``None`` to have them not required. Fields described here are queryables-specific and complete StacMetadata fields. """ start: Annotated[ str, Field( None, alias=AliasChoices("start_datetime", "datetime"), description="Date/time as string in ISO 8601 format (e.g. '2024-06-10T12:00:00Z')", ), ] end: Annotated[ str, Field( None, alias="end_datetime", description="Date/time as string in ISO 8601 format (e.g. '2024-06-10T12:00:00Z')", ), ] geom: Annotated[ Union[str, dict[str, float], BaseGeometry], Field( None, alias=AliasChoices("geometry", "intersects"), description="Read EODAG documentation for all supported geometry format.", ), ] id: Annotated[str, Field(None)] model_config = ConfigDict(arbitrary_types_allowed=True)
[docs] @field_validator("ecmwf_date", mode="plain", check_fields=False) @classmethod def check_date_range(cls, v: str) -> str: """Validate date ranges""" if not isinstance(v, str): raise ValueError( "date must be a string formatted as single date ('yyyy-mm-dd') or range ('yyyy-mm-dd/yyyy-mm-dd')" ) date_regex = [ re.compile(p) for p in ( DATE_PATTERN, COMPACT_DATE_PATTERN, DATE_RANGE_PATTERN, COMPACT_DATE_RANGE_PATTERN, ) ] if not any(r.match(v) is not None for r in date_regex): raise ValueError( "date must be a string formatted as single date ('yyyy-mm-dd') or range ('yyyy-mm-dd/yyyy-mm-dd')" ) try: start, end = parse_date(v) except EodagValidationError as e: raise ValueError("date must follow 'yyyy-mm-dd' format") from e if end < start: raise ValueError("date range end must be after start") # enumerate dates in range v_set: set[str] = {format_date(d) for d in datetime_range(start, end)} # is_range_in_range() support only ranges (no single date allowed) in the format 'yyyy-mm-dd/yyyy-mm-dd' v_range: str = format_date_range(start, end) field_info = cls.model_fields["ecmwf_date"] literals = get_args(field_info.annotation) # Collect missing values to report errors missing_values = set(v_set) # date constraint may be intervals. We identify intervals with a "/" in the value. # date constraint can be a mixed list of single values (e.g "2023-06-27") # and intervals (e.g. "2024-11-12/2025-11-20") # collections with mixed values: CAMS_GAC_FORECAST, CAMS_EU_AIR_QUALITY_FORECAST for literal in literals: literal_start, literal_end = parse_date(literal) if "/" in literal: # range with separator / or /to/ literal_range: str = format_date_range(literal_start, literal_end) if is_range_in_range(literal_range, v_range): return v else: # convert literal to the format 'yyyy-mm-dd' literal_start_str = format_date(literal_start) if literal_start_str in v_set: missing_values.remove(literal_start_str) if not missing_values: return v raise ValueError("date not allowed")
class QueryablesDict(UserDict[str, Any]): """Class inheriting from UserDict which contains queryables with their annotated type; :param additional_properties: if additional properties (properties not given in EODAG config) are allowed :param kwargs: named arguments to initialise the dict (queryable keys + annotated types) """ additional_properties: bool = Field(True) additional_information: str = Field("") def __init__( self, additional_properties: bool = True, additional_information: str = "", **kwargs: Any, ): self.additional_properties = additional_properties self.additional_information = additional_information super().__init__(kwargs) # sort queryables: first without then with extension prefix no_prefix_queryables = { key: self.data[key] for key in sorted(self.data) if ":" not in str( getattr(self.data[key], "__metadata__", [Field()])[0].alias or key ) } with_prefix_queryables = { key: self.data[key] for key in sorted(self.data) if ":" in str(getattr(self.data[key], "__metadata__", [Field()])[0].alias or key) } self.data = no_prefix_queryables | with_prefix_queryables def _repr_html_(self, embedded: bool = False) -> str: add_info = ( f"&ensp;additional_information={self.additional_information}" if self.additional_information else "" ) thead = ( f"""<thead><tr><td style='text-align: left; color: grey;'> {type(self).__name__}&ensp;({len(self)})&ensp;-&ensp;additional_properties={ self.additional_properties} """ + add_info + "</td></tr></thead>" if not embedded else "" ) tr_style = "style='background-color: transparent;'" if embedded else "" table = f"<table>{thead}<tbody>" table_rows = [] for k, v in self.items(): alias = v.__metadata__[0].validation_alias if not alias or alias == PydanticUndefined: alias = v.__metadata__[0].alias if not alias or alias == PydanticUndefined: alias = v.__metadata__[0].serialization_alias if isinstance(alias, AliasChoices): alias_str = ( "'alias': '<span style='color:grey'>AliasChoices(choices=[</span>'" + "'<span style='color:black'>" + '", "'.join([str(c) for c in alias.choices]) + "</span>'" + "'<span style='color:grey'>])</span>',&ensp;" ) elif alias and alias != PydanticUndefined: alias_str = ( "'alias': '<span style='color:black'>" + str(alias) + "</span>',&ensp;" ) else: alias_str = "" table_row = f"""<tr {tr_style}><td style='text-align: left;'> <details><summary style='color: grey;'> <span style='color: black'>'{k}'</span>:&ensp; typing.Annotated[{ "<span style='color: black'>" + shorter_type_repr(v.__args__[0]) + "</span>,&ensp;"} FieldInfo({"'default': '<span style='color: black'>" + str(v.__metadata__[0].get_default()) + "</span>',&ensp;" if v.__metadata__[0].get_default() and v.__metadata__[0].get_default() != PydanticUndefined else ""} {"'required': <span style='color: black'>" + str(v.__metadata__[0].is_required()) + "</span>," if v.__metadata__[0].is_required() else ""} {alias_str} ... )] </summary> <span style='color: grey'>typing.Annotated[</span><table style='margin: 0;'> <tr style='background-color: transparent;'> <td style='padding: 5px 0 0 10px; text-align: left; vertical-align:top;'> {remove_class_repr(str(v.__args__[0]))},</td> </tr><tr style='background-color: transparent;'> <td style='padding: 5px 0 0 10px; text-align: left; vertical-align:top;'> {v.__metadata__[0].__repr__()}</td> </tr> </table><span style='color: grey'>]</span> </details> </td></tr> """ table_rows.append(table_row) table += "".join(table_rows) table += "</tbody></table>" return table def get_model(self, model_name: str = "Queryables") -> BaseModel: """ Converts object from :class:`eodag.api.product.QueryablesDict` to :class:`pydantic.BaseModel` so that validation can be performed :param model_name: name used for :class:`pydantic.BaseModel` creation :return: pydantic BaseModel of the queryables dict """ return annotated_dict_to_model(model_name, self.data, Queryables)