Source code for tilearray.service.wcs

"""WCS (Web Coverage Service) XML parsing and tile request functionality."""

from __future__ import annotations

import logging
import re
import xml.etree.ElementTree as ET
from datetime import datetime
from typing import Any

import requests

from ..types import (
    CRS,
    BoundingBox,
    CoverageDescription,
    Format,
    ServiceCapabilities,
    ServiceTypeEnum,
    SpatialExtent,
    TemporalExtent,
    TileRequest,
    WCSResponse,
)
from .base import BaseService, TileGeometry, register_service

logger = logging.getLogger(__name__)

_OGC_EPSG_URI_RE = re.compile(r"/EPSG/(?:0/)?(\d+)/?$", re.I)


def normalize_crs_reference(value: str) -> str:
    """Normalize OGC URI or EPSG shorthand to ``EPSG:<code>``."""

    text = value.strip()
    upper = text.upper()
    if upper.startswith("EPSG:"):
        code = text.split(":", 1)[1].split("/")[0]
        return f"EPSG:{code}"
    match = _OGC_EPSG_URI_RE.search(text)
    if match:
        return f"EPSG:{match.group(1)}"
    return text


def _coerce_crs_enum(value: str | None) -> CRS | None:
    if not value:
        return None
    normalized = normalize_crs_reference(value)
    try:
        return CRS(normalized)
    except ValueError:
        try:
            return CRS.from_epsg(normalized)
        except ValueError:
            logger.debug("Skipping unsupported CRS reference '%s'", value)
            return None


class WCSParser:
    """Parser for WCS XML responses."""

    def __init__(self, base_url: str):
        self.base_url = base_url.rstrip("/")
        self.namespaces = {
            "wcs": "http://www.opengis.net/wcs/2.0",
            "ows": "http://www.opengis.net/ows/1.1",
            "gml": "http://www.opengis.net/gml/3.2",
            "crs": "http://www.opengis.net/wcs/crs/1.0",
            "xsi": "http://www.w3.org/2001/XMLSchema-instance",
        }

    def parse_get_capabilities(self, xml_content: str) -> ServiceCapabilities:
        try:
            root = ET.fromstring(xml_content)

            service_title = self._get_text(
                root, ".//ows:ServiceIdentification/ows:Title"
            )
            service_abstract = self._get_text(
                root, ".//ows:ServiceIdentification/ows:Abstract"
            )
            service_keywords = self._get_keywords(root)
            service_provider = self._get_text(
                root, ".//ows:ServiceProvider/ows:ProviderName"
            )
            service_contact = self._get_text(
                root,
                ".//ows:ServiceProvider/ows:ServiceContact/ows:ContactInfo/ows:ContactPersonPrimary/ows:ContactPerson",
            )

            operations: list[str] = []
            for op in root.findall(".//ows:Operation", self.namespaces):
                op_name = op.get("name")
                if op_name:
                    operations.append(op_name)

            supported_formats = self._parse_supported_formats(root)
            supported_crs = self._parse_supported_crs(root)
            coverages = self._parse_coverages(root)

            return ServiceCapabilities(
                service_title=service_title or "WCS Service",
                service_abstract=service_abstract,
                service_keywords=service_keywords,
                service_provider=service_provider,
                service_contact=service_contact,
                service_url=self.base_url,
                supported_operations=operations,
                supported_formats=supported_formats,
                supported_crs=supported_crs,
                coverages=coverages,
            )
        except ET.ParseError as exc:  # pragma: no cover - defensive
            raise ValueError(f"Invalid XML content: {exc}") from exc

    def parse_describe_coverage(self, xml_content: str) -> CoverageDescription:
        try:
            root = ET.fromstring(xml_content)

            coverage_elem = root.find(".//wcs:CoverageDescription", self.namespaces)
            if coverage_elem is None and root.tag.lower().endswith(
                "coveragedescription"
            ):
                coverage_elem = root
            if coverage_elem is None:
                raise ValueError("No coverage description found in XML")

            identifier = self._get_text(coverage_elem, ".//gml:identifier")
            if not identifier:
                identifier = self._get_text(coverage_elem, ".//wcs:CoverageId")
            if not identifier:
                raise ValueError("Coverage identifier not found")

            title = self._get_text(coverage_elem, ".//gml:name")
            abstract = self._get_text(coverage_elem, ".//gml:description")
            keywords = self._get_keywords(coverage_elem)
            supported_crs = self._parse_coverage_crs(coverage_elem)
            supported_formats = self._parse_coverage_formats(coverage_elem)
            spatial_extent = self._parse_spatial_extent(coverage_elem)
            temporal_extent = self._parse_temporal_extent(coverage_elem)
            native_crs, axis_labels = self._parse_envelope_metadata(coverage_elem)
            native_format = self._parse_native_format(coverage_elem)

            if native_format and native_format not in supported_formats:
                supported_formats.append(native_format)
            if native_crs and native_crs not in supported_crs:
                supported_crs.append(native_crs)

            return CoverageDescription(
                identifier=identifier,
                title=title,
                abstract=abstract,
                keywords=keywords,
                supported_crs=supported_crs,
                supported_formats=supported_formats,
                spatial_extent=spatial_extent,
                temporal_extent=temporal_extent,
                native_crs=native_crs,
                axis_labels=axis_labels,
                native_format=native_format,
            )
        except ET.ParseError as exc:  # pragma: no cover - defensive
            raise ValueError(f"Invalid XML content: {exc}") from exc

    # ------------------------------------------------------------------
    # Internal helpers
    # ------------------------------------------------------------------
    def _get_text(self, element: ET.Element, xpath: str) -> str | None:
        elem = element.find(xpath, self.namespaces)
        return elem.text.strip() if elem is not None and elem.text else None

    def _get_keywords(self, element: ET.Element) -> list[str]:
        keywords: list[str] = []
        for kw_elem in element.findall(".//ows:Keywords/ows:Keyword", self.namespaces):
            if kw_elem.text:
                keywords.append(kw_elem.text.strip())
        return keywords

    def _parse_supported_formats(self, root: ET.Element) -> list[Format]:
        formats: list[Format] = []
        for format_elem in root.findall(".//wcs:SupportedFormat", self.namespaces):
            if format_elem.text:
                text = format_elem.text.strip()
                try:
                    formats.append(Format(text))
                except ValueError:
                    logger.debug("Skipping unsupported WCS format '%s'", text)
        for format_elem in root.findall(".//wcs:formatSupported", self.namespaces):
            if format_elem.text:
                text = format_elem.text.strip()
                try:
                    fmt = Format(text)
                except ValueError:
                    logger.debug("Skipping unsupported WCS format '%s'", text)
                else:
                    if fmt not in formats:
                        formats.append(fmt)
        return formats

    def _parse_supported_crs(self, root: ET.Element) -> list[CRS]:
        crs_list: list[CRS] = []
        for crs_elem in root.findall(".//wcs:SupportedCRS", self.namespaces):
            if crs_elem.text:
                text = crs_elem.text.strip()
                try:
                    crs_list.append(CRS(text))
                except ValueError:
                    logger.debug("Skipping unsupported CRS '%s'", text)
        for crs_elem in root.findall(".//crs:crsSupported", self.namespaces):
            if crs_elem.text:
                coerced = _coerce_crs_enum(crs_elem.text.strip())
                if coerced is not None and coerced not in crs_list:
                    crs_list.append(coerced)
        return crs_list

    def _parse_coverages(self, root: ET.Element) -> list[CoverageDescription]:
        coverages: list[CoverageDescription] = []
        for coverage_elem in root.findall(
            ".//wcs:Contents/wcs:CoverageSummary", self.namespaces
        ):
            identifier = self._get_text(coverage_elem, ".//wcs:Identifier")
            if not identifier:
                identifier = self._get_text(coverage_elem, ".//wcs:CoverageId")
            if identifier:
                coverages.append(
                    CoverageDescription(
                        identifier=identifier,
                        title=self._get_text(coverage_elem, ".//wcs:Title"),
                        abstract=self._get_text(coverage_elem, ".//wcs:Abstract"),
                        keywords=self._get_keywords(coverage_elem),
                        native_crs=None,
                        native_format=None,
                    )
                )
        return coverages

    def _parse_coverage_crs(self, coverage_elem: ET.Element) -> list[CRS]:
        crs_list: list[CRS] = []
        for crs_elem in coverage_elem.findall(".//wcs:SupportedCRS", self.namespaces):
            if crs_elem.text:
                text = crs_elem.text.strip()
                try:
                    crs_list.append(CRS(text))
                except ValueError:
                    logger.debug("Skipping unsupported CRS '%s'", text)
        return crs_list

    def _parse_coverage_formats(self, coverage_elem: ET.Element) -> list[Format]:
        formats: list[Format] = []
        for format_elem in coverage_elem.findall(
            ".//wcs:SupportedFormat", self.namespaces
        ):
            if format_elem.text:
                text = format_elem.text.strip()
                try:
                    formats.append(Format(text))
                except ValueError:
                    logger.debug("Skipping unsupported format '%s'", text)
        return formats

    def _find_envelope(self, coverage_elem: ET.Element) -> ET.Element | None:
        envelope = coverage_elem.find(".//gml:boundedBy/gml:Envelope", self.namespaces)
        if envelope is None:
            envelope = coverage_elem.find(".//gml:Envelope", self.namespaces)
        return envelope

    def _parse_envelope_metadata(
        self, coverage_elem: ET.Element
    ) -> tuple[CRS | None, dict[str, tuple[str, str]]]:
        envelope = self._find_envelope(coverage_elem)
        if envelope is None:
            return None, {}

        srs_name = envelope.get("srsName")
        axis_labels_raw = envelope.get("axisLabels")
        if not srs_name:
            return None, {}

        normalized_crs = normalize_crs_reference(srs_name)
        native_crs = _coerce_crs_enum(normalized_crs)
        axis_labels: dict[str, tuple[str, str]] = {}
        if axis_labels_raw:
            parts = axis_labels_raw.split()
            if len(parts) >= 2:
                axis_labels[normalized_crs] = (parts[0], parts[1])
        return native_crs, axis_labels

    def _parse_native_format(self, coverage_elem: ET.Element) -> Format | None:
        native_format_elem = coverage_elem.find(
            ".//wcs:ServiceParameters/wcs:nativeFormat", self.namespaces
        )
        if native_format_elem is None or not native_format_elem.text:
            return None
        text = native_format_elem.text.strip()
        try:
            return Format(text)
        except ValueError:
            logger.debug("Skipping unsupported native format '%s'", text)
            return None

    def _parse_spatial_extent(self, coverage_elem: ET.Element) -> SpatialExtent | None:
        envelope = self._find_envelope(coverage_elem)
        if envelope is None:
            return None

        lower_corner = envelope.find(".//gml:lowerCorner", self.namespaces)
        upper_corner = envelope.find(".//gml:upperCorner", self.namespaces)
        if not (lower_corner is not None and upper_corner is not None):
            return None

        lower_text = (lower_corner.text or "").strip()
        upper_text = (upper_corner.text or "").strip()
        if not lower_text or not upper_text:
            return None

        try:
            lower_coords = [float(x) for x in lower_text.split()]
            upper_coords = [float(x) for x in upper_text.split()]
        except (AttributeError, ValueError):
            return None

        if len(lower_coords) < 2 or len(upper_coords) < 2:
            return None

        srs_name = envelope.get("srsName")
        bbox_crs = _coerce_crs_enum(normalize_crs_reference(srs_name or ""))
        if bbox_crs is None:
            bbox_crs = self._parse_native_crs(coverage_elem)

        bbox = BoundingBox(
            min_x=lower_coords[0],
            min_y=lower_coords[1],
            max_x=upper_coords[0],
            max_y=upper_coords[1],
            crs=bbox_crs,
        )
        return SpatialExtent(bbox=bbox, dimensions=None)

    def _parse_temporal_extent(
        self, coverage_elem: ET.Element
    ) -> TemporalExtent | None:
        time_elem = coverage_elem.find(".//gml:TimePeriod", self.namespaces)
        if time_elem is None:
            return None

        begin_elem = time_elem.find(".//gml:beginPosition", self.namespaces)
        end_elem = time_elem.find(".//gml:endPosition", self.namespaces)

        start_time = self._parse_datetime(
            begin_elem.text if begin_elem is not None else None
        )
        end_time = self._parse_datetime(end_elem.text if end_elem is not None else None)

        if start_time or end_time:
            return TemporalExtent(start_time=start_time, end_time=end_time)
        return None

    def _parse_datetime(self, value: str | None) -> datetime | None:
        if not value:
            return None
        try:
            return datetime.fromisoformat(value.replace("Z", "+00:00"))
        except ValueError:
            logger.debug("Failed to parse datetime '%s'", value)
            return None

    def _parse_native_crs(self, coverage_elem: ET.Element) -> CRS:
        native_crs, _ = self._parse_envelope_metadata(coverage_elem)
        if native_crs is not None:
            return native_crs

        native_crs_elem = coverage_elem.find(".//wcs:NativeCRS", self.namespaces)
        if native_crs_elem is not None and native_crs_elem.text:
            coerced = _coerce_crs_enum(native_crs_elem.text.strip())
            if coerced is not None:
                return coerced
        return CRS.EPSG_4326


[docs] @register_service(ServiceTypeEnum.WCS) class WCSService(BaseService): """Client for interacting with WCS endpoints."""
[docs] def __init__( self, base_url: str, *, version: str = "2.0.1", session: requests.Session | None = None, coverage_id: str | None = None, output_format: Format | None = None, crs: CRS | None = None, **config: Any, ) -> None: super().__init__(base_url, version=version, **config) self.session = session or requests.Session() self.version = version self.coverage_id = coverage_id or config.get("layer_id") self.parser = WCSParser(self.base_url) self.output_format = self._coerce_format( output_format or config.get("format") or Format.GEOTIFF ) self.subsetting_crs = self._coerce_crs( crs or config.get("crs") or CRS.EPSG_4326 ) self._coverage_metadata: dict[str, CoverageDescription] = {}
@classmethod def from_url(cls, url: str, **config: Any) -> WCSService: return cls(url, **config) def set_coverage_metadata(self, description: CoverageDescription) -> None: """Cache DescribeCoverage metadata for GetCoverage request building.""" self._coverage_metadata[description.identifier] = description def coverage_metadata( self, coverage_id: str | None = None ) -> CoverageDescription | None: coverage = coverage_id or self.coverage_id if not coverage: return None return self._coverage_metadata.get(coverage) def ensure_coverage_metadata( self, coverage_id: str | None = None, **params: Any ) -> CoverageDescription: coverage = coverage_id or self._require_coverage_id() cached = self._coverage_metadata.get(coverage) if cached is not None: return cached description = self.describe_coverage(coverage, **params) self.set_coverage_metadata(description) return description # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ def get_capabilities(self, **params: Any) -> ServiceCapabilities: response = self.session.get( self.base_url, params={ "service": "WCS", "version": self.version, "request": "GetCapabilities", **params, }, ) response.raise_for_status() return self.parser.parse_get_capabilities(response.text) def describe_coverage( self, coverage_id: str | None = None, **params: Any ) -> CoverageDescription: coverage = coverage_id or self._require_coverage_id() response = self.session.get( self.base_url, params={ "service": "WCS", "version": self.version, "request": "DescribeCoverage", "coverageId": coverage, **params, }, ) response.raise_for_status() description = self.parser.parse_describe_coverage(response.text) self.set_coverage_metadata(description) return description def get_coverage( self, coverage_id: str | None, bbox: BoundingBox, width: int, height: int, *, output_format: Format | None = None, crs: CRS | None = None, **params: Any, ) -> WCSResponse: coverage = coverage_id or self._require_coverage_id() metadata = self.ensure_coverage_metadata(coverage) fmt = self._coerce_format( output_format or self.output_format or metadata.native_format or Format.GEOTIFF ) subset_crs, subset_bbox, subset_parts = self._resolve_subset( bbox, crs or self.subsetting_crs, metadata ) request_params = { "service": "WCS", "version": self.version, "request": "GetCoverage", "coverageId": coverage, "subset": subset_parts, "format": fmt.value, "width": str(width), "height": str(height), "subsettingCRS": subset_crs.value, **params, } try: response = self.session.get(self.base_url, params=request_params) response.raise_for_status() return WCSResponse( success=True, data=response.content, error_message=None, status_code=response.status_code, ) except requests.RequestException as exc: logger.debug("WCS GetCoverage failed: %s", exc, exc_info=True) status_code = exc.response.status_code if exc.response is not None else None return WCSResponse( success=False, data=None, error_message=str(exc), status_code=status_code, ) # ------------------------------------------------------------------ # BaseService overrides # ------------------------------------------------------------------ def build_tile_request(self, tile: TileGeometry, **options: Any) -> TileRequest: coverage = options.get("coverage_id") or self.coverage_id if not coverage: raise ValueError("WCS coverage_id must be provided") metadata = self._metadata_or_stub(coverage) fmt = self._coerce_format( options.get("output_format") or self.output_format or metadata.native_format ) requested_crs = self._coerce_crs(options.get("crs") or tile.crs) subset_crs, subset_bbox, subset_parts = self._resolve_subset( tile.bbox, requested_crs, metadata ) subset_tile = TileGeometry( bbox=subset_bbox, width=tile.width, height=tile.height, crs=subset_crs, tile_x=tile.tile_x, tile_y=tile.tile_y, zoom=tile.zoom, ) params: dict[str, Any] = { "service": "WCS", "version": self.version, "request": "GetCoverage", "coverageId": coverage, "subset": subset_parts, "format": fmt.value, "width": str(tile.width), "height": str(tile.height), "subsettingCRS": subset_crs.value, } passthrough = { key: value for key, value in options.items() if key not in {"output_format", "crs"} } return self.compose_tile_request( subset_tile, url=self.base_url, params=params, output_format=fmt, crs=subset_crs, **passthrough, ) # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _require_coverage_id(self) -> str: if not self.coverage_id: raise ValueError("WCS coverage_id is required but was not provided") return self.coverage_id def _metadata_or_stub(self, coverage_id: str) -> CoverageDescription: cached = self.coverage_metadata(coverage_id) if cached is not None: return cached return CoverageDescription( identifier=coverage_id, native_crs=None, native_format=None, ) def _coerce_format(self, fmt: Any) -> Format: if isinstance(fmt, Format): return fmt if isinstance(fmt, str): try: return Format(fmt) except ValueError as exc: raise ValueError(f"Unsupported WCS format: {fmt}") from exc raise ValueError(f"Invalid WCS format value: {fmt!r}") def _coerce_crs(self, crs: Any) -> CRS: if isinstance(crs, CRS): return crs if isinstance(crs, str): return ( CRS.from_epsg(crs) if crs.upper().startswith("EPSG:") else CRS.from_integer(int(crs)) ) if isinstance(crs, int): return CRS.from_integer(crs) raise ValueError(f"Invalid CRS value: {crs!r}") def _resolve_subset( self, bbox: BoundingBox, requested_crs: CRS, metadata: CoverageDescription, ) -> tuple[CRS, BoundingBox, list[str]]: subset_crs = requested_crs subset_bbox = bbox if bbox.crs == requested_crs else bbox.to_crs(requested_crs) axis_labels = metadata.axis_labels.get(requested_crs.value) if axis_labels is None and metadata.native_crs is not None: native_key = metadata.native_crs.value native_axes = metadata.axis_labels.get(native_key) if native_axes is not None: subset_crs = metadata.native_crs subset_bbox = subset_bbox.to_crs(subset_crs) axis_labels = native_axes if axis_labels is None: axis_labels = self._default_subset_axes(subset_crs) return subset_crs, subset_bbox, self._format_subset(subset_bbox, axis_labels) def _default_subset_axes(self, crs: CRS) -> tuple[str, str]: if crs == CRS.EPSG_27700: return ("E", "N") if crs == CRS.EPSG_3857: return ("x", "y") return ("Long", "Lat") def _format_subset( self, bbox: BoundingBox, axis_labels: tuple[str, str] ) -> list[str]: axis_x, axis_y = axis_labels return [ f"{axis_x}({bbox.min_x},{bbox.max_x})", f"{axis_y}({bbox.min_y},{bbox.max_y})", ]