Source code for pystac.validation.stac_validator

import json
import logging
import warnings
from abc import ABC, abstractmethod
from pathlib import Path, PurePosixPath
from typing import Any, cast
from urllib.parse import urlparse

import pystac
import pystac.utils
from pystac.errors import STACValidationError
from pystac.stac_object import STACObjectType
from pystac.validation.schema_uri_map import DefaultSchemaUriMap, SchemaUriMap

try:
    import jsonschema
    import jsonschema.exceptions
    import jsonschema.validators
    from referencing import Registry, Resource

    from pystac.validation.local_validator import get_local_schema_cache

    HAS_JSONSCHEMA = True
except ImportError:
    HAS_JSONSCHEMA = False

logger = logging.getLogger(__name__)


[docs] class GetSchemaError(Exception): """Raised when unable to fetch a schema.""" def __init__(self, href: str, error: Exception) -> None: super().__init__(f"Error when fetching schema {href}: {error}")
[docs] class STACValidator(ABC): """STACValidator defines methods for validating STAC JSON. Implementations define methods for validating core objects and extension. By default, the JsonSchemaSTACValidator is used by PySTAC; users can define their own STACValidator implementation and set that validator to be used by pystac by using the :func:`~pystac.validation.set_validator` method. """
[docs] @abstractmethod def validate_core( self, stac_dict: dict[str, Any], stac_object_type: STACObjectType, stac_version: str, href: str | None = None, ) -> Any: """Validate a core stac object. Return value can be None or specific to the implementation. Args: stac_dict : Dictionary that is the STAC JSON of the object. stac_object_type : The stac object type of the object encoded in stac_dict. One of :class:`~pystac.STACObjectType`. stac_version : The version of STAC to validate the object against. href : Optional HREF of the STAC object being validated. """ raise NotImplementedError
[docs] @abstractmethod def validate_extension( self, stac_dict: dict[str, Any], stac_object_type: STACObjectType, stac_version: str, extension_id: str, href: str | None = None, ) -> Any: """Validate an extension stac object. Return value can be None or specific to the implementation. Args: stac_dict : Dictionary that is the STAC JSON of the object. stac_object_type : The stac object type of the object encoded in stac_dict. One of :class:`~pystac.STACObjectType`. stac_version : The version of STAC to validate the object against. extension_id : The extension ID of the extension to validate against. href : Optional HREF of the STAC object being validated. """ raise NotImplementedError
[docs] def validate( self, stac_dict: dict[str, Any], stac_object_type: STACObjectType, stac_version: str, extensions: list[str], href: str | None = None, ) -> list[Any]: """Validate a STAC object JSON. Args: stac_dict : Dictionary that is the STAC JSON of the object. stac_object_type : The stac object type of the object encoded in stac_dict. One of :class:`~pystac.STACObjectType`. stac_version : The version of STAC to validate the object against. extensions : Extension IDs for this stac object. href : Optional href of the STAC object being validated. Returns: List[Any]: List of return values from the validation calls for the core object and any extensions. Element type is specific to the STACValidator implementation. """ results: list[Any] = [] # Pass the dict through JSON serialization and parsing, otherwise # some valid properties can be marked as invalid (e.g. tuples in # coordinate sequences for geometries). json_dict = json.loads(json.dumps(stac_dict)) core_result = self.validate_core( json_dict, stac_object_type, stac_version, href ) if core_result is not None: results.append(core_result) for extension_id in extensions: ext_result = self.validate_extension( json_dict, stac_object_type, stac_version, extension_id, href ) if ext_result is not None: results.append(ext_result) return results
[docs] class JsonSchemaSTACValidator(STACValidator): """Validate STAC based on JSON Schemas. This validator uses JSON schemas, read from URIs provided by a :class:`~pystac.validation.schema_uri_map.SchemaUriMap`, to validate STAC core objects and extensions. Args: schema_uri_map : The :class:`~pystac.validation.schema_uri_map.SchemaUriMap` that defines where the validator will retrieve the JSON schemas for validation. Defaults to an instance of :class:`~pystac.validation.schema_uri_map.DefaultSchemaUriMap` allow_list: Optional list of base urls that are the validator can read from when validating. Defaults to ``None`` which means that the validator can read from anywhere. Note: This class requires the ``jsonschema`` library to be installed. """ schema_uri_map: SchemaUriMap schema_cache: dict[str, dict[str, Any]] allow_list: list[str] | None = None def __init__( self, schema_uri_map: SchemaUriMap | None = None, allow_list: list[str] | None = None, ) -> None: if not HAS_JSONSCHEMA: raise ImportError("Cannot instantiate, requires jsonschema package") if schema_uri_map is not None: self.schema_uri_map = schema_uri_map else: self.schema_uri_map = DefaultSchemaUriMap() self.allow_list = self.allow_list if allow_list is None else allow_list self.schema_cache = get_local_schema_cache() def _get_schema(self, schema_uri: str) -> dict[str, Any]: if schema_uri not in self.schema_cache: try: if self.allow_list is not None and not any( [schema_uri.startswith(base_url) for base_url in self.allow_list] ): raise ValueError( f"{schema_uri=} is not in the allow-list {self.allow_list}" ) s = json.loads(pystac.StacIO.default().read_text(schema_uri)) except Exception as error: raise GetSchemaError(schema_uri, error) from error self.schema_cache[schema_uri] = s id_field = "$id" if "$id" in s else "id" if not s[id_field].startswith("http"): s[id_field] = schema_uri return self.schema_cache[schema_uri] @property def registry(self) -> Any: def retrieve(schema_uri: str) -> Resource[dict[str, Any]]: return Resource.from_contents(self._get_schema(schema_uri)) return Registry(retrieve=retrieve).with_resources( # type: ignore [(k, Resource.from_contents(v)) for k, v in self.schema_cache.items()] )
[docs] def get_schema_from_uri(self, schema_uri: str) -> tuple[dict[str, Any], Any]: """DEPRECATED""" warnings.warn( "get_schema_from_uri is deprecated and will be removed in v2.", DeprecationWarning, ) return self._get_schema(schema_uri), self.registry
def _validate_from_uri( self, stac_dict: dict[str, Any], stac_object_type: STACObjectType, schema_uri: str, href: str | None = None, ) -> None: try: schema = self._get_schema(schema_uri) # This block is cribbed (w/ change in error handling) from # jsonschema.validate cls = jsonschema.validators.validator_for(schema) cls.check_schema(schema) validator = cls(schema, registry=self.registry) errors = list(validator.iter_errors(stac_dict)) except Exception as e: logger.error(f"Exception while validating {stac_object_type} href: {href}") logger.exception(e) raise if errors: stac_id = stac_dict.get("id", None) msg = f"Validation failed for {stac_object_type} " if href is not None: msg += f"at {href} " if stac_id is not None: msg += f"with ID {stac_id} " msg += f"against schema at {schema_uri}" best = jsonschema.exceptions.best_match(errors) if best: msg += "\n" + str(best) raise STACValidationError(msg, source=errors) from best
[docs] def validate_core( self, stac_dict: dict[str, Any], stac_object_type: STACObjectType, stac_version: str, href: str | None = None, ) -> str | None: """Validate a core stac object. Return value can be None or specific to the implementation. Args: stac_dict : Dictionary that is the STAC JSON of the object. stac_object_type : The stac object type of the object encoded in stac_dict. One of :class:`~pystac.STACObjectType`. stac_version : The version of STAC to validate the object against. href : Optional HREF of the STAC object being validated. Returns: str: URI for the JSON schema that was validated against, or None if no validation occurred. Raises: STACValidationError: if stac_dict is not valid. The exception is raised from the "best" error, as determined by the jsonschema library. To access all jsonschema validation errors, use ``STACValidationError.source``. """ schema_uri = self.schema_uri_map.get_object_schema_uri( stac_object_type, stac_version ) if schema_uri is None: return None self._validate_from_uri(stac_dict, stac_object_type, schema_uri, href=href) return schema_uri
[docs] def validate_extension( self, stac_dict: dict[str, Any], stac_object_type: STACObjectType, stac_version: str, extension_id: str, href: str | None = None, ) -> str | None: """Validate an extension stac object. Return value can be None or specific to the implementation. Args: stac_dict : Dictionary that is the STAC JSON of the object. stac_object_type : The stac object type of the object encoded in stac_dict. One of :class:`~pystac.STACObjectType`. stac_version : The version of STAC to validate the object against. extension_id : The extension ID to validate against. href : Optional HREF of the STAC object being validated. Returns: str: URI for the JSON schema that was validated against, or None if no validation occurred. Raises: STACValidationError: if stac_dict is not valid. The exception is raised from the "best" error, as determined by the jsonschema library. To access all jsonschema validation errors, use ``STACValidationError.source``. """ schema_uri = extension_id if schema_uri is None: return None schema_uri = pystac.utils.make_absolute_href(schema_uri, href) # if the extension class is installed and has a local schema file, # try to read and cache that. Assumes that the schema_uri ends with # name/version/schema.json for example: # https://stac-extensions.github.io/eo/v1.1.0/schema.json # and that the local schema file is at # pystac/extensions/json-schema/eo/v1.1.0.json if schema_uri not in self.schema_cache: import importlib.resources for schema_dir in importlib.resources.files( "pystac.extensions.json-schema" ).iterdir(): for schema_filepath in schema_dir.iterdir(): uri_path = PurePosixPath(urlparse(schema_uri).path) version = uri_path.parent.name name = uri_path.parent.parent.name if ( schema_filepath.is_file() and isinstance(schema_filepath, Path) and schema_filepath.parent.name == name and schema_filepath.name == f"{version}.json" ): with schema_filepath.open("r") as f: schema = cast(dict[str, Any], json.load(f)) if schema["$id"] not in {schema_uri, f"{schema_uri}#"}: raise ValueError( f"Local version of extension schema is invalid. " f"Expected {schema_uri}, found {schema['$id']}" ) self.schema_cache[schema_uri] = schema self._validate_from_uri(stac_dict, stac_object_type, schema_uri, href) return schema_uri
[docs] class SecureJsonSchemaSTACValidator(JsonSchemaSTACValidator): """Validate STAC, but only allow fetching schemas from domains that are standard to the STAC organization. """ allow_list: list[str] = [ "https://schemas.stacspec.org/", "https://stac-extensions.github.io/", ]