Skip to content
23 changes: 15 additions & 8 deletions mapchete_eo/search/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from abc import ABC, abstractmethod
from typing import Any, Callable, Dict, Generator, List, Optional, Type, Union

from cql2 import Expr
from pygeofilter.parsers.ecql import parse as parse_ecql
from pygeofilter.backends.native.evaluate import NativeEvaluator
from pydantic import BaseModel
from mapchete.path import MPath, MPathLike
from mapchete.types import Bounds
Expand Down Expand Up @@ -239,10 +240,16 @@ def filter_items(
the field and value for the item filter would be defined in search.config.py corresponding configs
and passed down to the individual search approaches via said config and this Function.
"""
if query:
expr = Expr(query)
for item in items:
if expr.matches(item.properties):
yield item
else:
yield from items
from mapchete_eo.search.config import parse_cql_query

with parse_cql_query():
if query:
ast = parse_ecql(query)
evaluator = NativeEvaluator(use_getattr=False)
filter_func = evaluator.evaluate(ast)
for item in items:
# pystac items store metadata in 'properties'
if filter_func(item.properties):
yield item
else:
yield from items
55 changes: 55 additions & 0 deletions mapchete_eo/search/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import re


from contextlib import contextmanager
Expand All @@ -16,6 +17,12 @@ class StacSearchConfig(BaseModel):
catalog_pagesize: int = 100
footprint_buffer: float = 0

@model_validator(mode="before")
def preprocess_query(cls, values: Dict[str, Any]) -> Dict[str, Any]:
if "query" in values and isinstance(values["query"], str):
values["query"] = quote_cql_query(values["query"])
return values

@model_validator(mode="before")
def deprecate_max_cloud_cover(cls, values: Dict[str, Any]) -> Dict[str, Any]:
if "max_cloud_cover" in values: # pragma: no cover
Expand Down Expand Up @@ -108,3 +115,51 @@ def _safe_migrate(self, obj, version, info):
finally:
# Restore original
FileExtensionHooks.migrate = _original_migrate


@contextmanager
def parse_cql_query():
"""
Context manager/decorator to automatically quote EO identifiers in ECQL queries
containing colons (e.g., eo:cloud_cover -> "eo:cloud_cover").

This is necessary for pygeofilter's ECQL parser.
"""
try:
import pygeofilter.parsers.ecql as ecql
except ImportError: # pragma: no cover
yield
return

_original_parse = ecql.parse
# monkeypatch parse to automatically quote query
ecql.parse = lambda query, *args, **kwargs: _original_parse(
quote_cql_query(query), *args, **kwargs
)
try:
yield
finally:
# restore original parse
ecql.parse = _original_parse


def quote_cql_query(query: str) -> str:
"""
Automatically quotes identifiers containing colons if not already quoted.

This is necessary for pygeofilter's ECQL parser.
"""
if not query or not isinstance(query, str):
return query

def replace(match):
text = match.group(0)
if text.startswith('"') and text.endswith('"'):
return text
if ":" in text:
return f'"{text}"'
return text

# Match existing double quoted strings or identifiers with colons
pattern = r'"[^"]*"|[a-zA-Z_][a-zA-Z0-9_]*:[0-9a-zA-Z_:]+'
return re.sub(pattern, replace, query)
8 changes: 6 additions & 2 deletions mapchete_eo/search/stac_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@

from mapchete_eo.product import blacklist_products
from mapchete_eo.search.base import CollectionSearcher, StaticCollectionWriterMixin
from mapchete_eo.search.config import StacSearchConfig, patch_invalid_assets
from mapchete_eo.search.config import (
StacSearchConfig,
patch_invalid_assets,
parse_cql_query,
)
from mapchete_eo.settings import mapchete_eo_settings
from mapchete_eo.types import TimeRange

Expand Down Expand Up @@ -138,7 +142,7 @@ def _search_chunks(
query=query,
)

with patch_invalid_assets():
with patch_invalid_assets(), parse_cql_query():
for search in _searches():
for item in search.items():
if item.get_self_href() in self.blacklist: # pragma: no cover
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ classifiers = [
]
dependencies = [
"click",
"cql2",
"pygeofilter",
"croniter",
"lxml",
"mapchete[complete]>=2025.10.0",
Expand Down
2 changes: 1 addition & 1 deletion tests/platforms/sentinel2/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from mapchete.formats import available_input_formats
from mapchete.geometry import to_shape
from mapchete.path import MPath
from pytest_lazyfixture import lazy_fixture
from pytest_lazy_fixtures import lf as lazy_fixture
from shapely.geometry import Point

from mapchete_eo.array.convert import to_masked_array
Expand Down
2 changes: 1 addition & 1 deletion tests/platforms/sentinel2/test_metadata_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
except ImportError:
from mapchete.types import Bounds, Grid
from pystac import Item
from pytest_lazyfixture import lazy_fixture
from pytest_lazy_fixtures import lf as lazy_fixture
from rasterio.crs import CRS
from shapely.geometry import shape

Expand Down
2 changes: 1 addition & 1 deletion tests/platforms/sentinel2/test_product.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from mapchete import Bounds
except ImportError:
from mapchete.types import Bounds
from pytest_lazyfixture import lazy_fixture
from pytest_lazy_fixtures import lf as lazy_fixture
from rasterio.crs import CRS

from mapchete_eo.exceptions import (
Expand Down
2 changes: 1 addition & 1 deletion tests/test_array.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import numpy as np
import pytest
import xarray as xr
from pytest_lazyfixture import lazy_fixture
from pytest_lazy_fixtures import lf as lazy_fixture

from mapchete_eo.array.buffer import buffer_array
from mapchete_eo.array.convert import to_dataarray, to_dataset, to_masked_array
Expand Down
2 changes: 1 addition & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pytest
from click.testing import CliRunner
from mapchete.io import rasterio_open
from pytest_lazyfixture import lazy_fixture
from pytest_lazy_fixtures import lf as lazy_fixture

from mapchete_eo.cli import eo

Expand Down
2 changes: 1 addition & 1 deletion tests/test_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import pytest
from mapchete.path import MPath
from mapchete.types import Bounds
from pytest_lazyfixture import lazy_fixture
from pytest_lazy_fixtures import lf as lazy_fixture
from shapely.geometry import shape

from mapchete_eo.io import get_item_property, item_fix_footprint, products_to_slices
Expand Down
2 changes: 1 addition & 1 deletion tests/test_s2_mgrs.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import pytest
from mapchete.io.vector import reproject_geometry
from mapchete.types import Bounds
from pytest_lazyfixture import lazy_fixture
from pytest_lazy_fixtures import lf as lazy_fixture
from shapely.geometry import box, shape

from mapchete_eo.search.s2_mgrs import InvalidMGRSSquare, S2Tile, s2_tiles_from_bounds
Expand Down
Loading