Repository: stac-utils/stac-validator

Issue:
Update dependencies for JSON schema ref resolving
Currently, I get the following warning:

> ../../../../home/mm/.local/lib/python3.10/site-packages/stac_validator/validate.py:9
> ../../../../home/mm/.local/lib/python3.10/site-packages/stac_validator/validate.py:9
  /home/mm/.local/lib/python3.10/site-packages/stac_validator/validate.py:9: DeprecationWarning: jsonschema.RefResolver is deprecated as of v4.18.0, in favor of the https://github.com/python-jsonschema/referencing library, which provides more compliant referencing behavior as well as more flexible APIs for customization. A future release will remove RefResolver. Please file a feature request (on referencing) if you are missing an API for the kind of customization 
you need.
    from jsonschema import RefResolver

Discussion:
@jonhealy1: I think this can be closed

Solution patch:
diff --git a/.github/workflows/test-runner.yml b/.github/workflows/test-runner.yml
index 82d6665..37f5b1e 100644
--- a/.github/workflows/test-runner.yml
+++ b/.github/workflows/test-runner.yml
@@ -25,7 +25,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
 
-      - name: Run unit tests
+      - name: Run mypy
         run: |
           pip install .
           pip install -r requirements-dev.txt
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 07d8ac2..727ddbe 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -12,7 +12,7 @@ repos:
     rev: 24.1.1
     hooks:
       - id: black
-        language_version: python3.10
+        # language_version: python3.11
   - repo: https://github.com/pre-commit/mirrors-mypy
     rev: v1.8.0
     hooks:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 31a6811..875f9a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,10 @@ The format is (loosely) based on [Keep a Changelog](http://keepachangelog.com/)
 - Configure whether to open URLs when validating assets [#238](https://github.com/stac-utils/stac-validator/pull/238)
 - Allow to provide HTTP headers [#239](https://github.com/stac-utils/stac-validator/pull/239)
 
+### Changed
+
+- Switched to the referencing library for dynamic JSON schema validation and reference resolution [#241](https://github.com/stac-utils/stac-validator/pull/241)
+
 ## [v3.4.0] - 2024-10-08
 
 ### Added
diff --git a/setup.py b/setup.py
index 700549f..84d4ba9 100644
--- a/setup.py
+++ b/setup.py
@@ -26,9 +26,10 @@ setup(
     long_description_content_type="text/markdown",
     url="https://github.com/stac-utils/stac-validator",
     install_requires=[
-        "requests>=2.19.1",
-        "jsonschema>=3.2.0",
-        "click>=8.0.0",
+        "requests>=2.32.3",
+        "jsonschema>=4.23.0",
+        "click>=8.1.8",
+        "referencing>=0.35.1",
     ],
     extras_require={
         "dev": [
diff --git a/stac_validator/utilities.py b/stac_validator/utilities.py
index 8d10e6e..a6b24e6 100644
--- a/stac_validator/utilities.py
+++ b/stac_validator/utilities.py
@@ -1,11 +1,17 @@
 import functools
 import json
 import ssl
-from typing import Dict
+from typing import Dict, Optional
 from urllib.parse import urlparse
 from urllib.request import Request, urlopen
 
+import jsonschema
 import requests  # type: ignore
+from jsonschema import Draft202012Validator
+from referencing import Registry, Resource
+from referencing.jsonschema import DRAFT202012
+from referencing.retrieval import to_cached_resource
+from referencing.typing import URI
 
 NEW_VERSIONS = [
     "1.0.0-beta.2",
@@ -77,7 +83,7 @@ def get_stac_type(stac_content: Dict) -> str:
         return str(e)
 
 
-def fetch_and_parse_file(input_path: str, headers: Dict = {}) -> Dict:
+def fetch_and_parse_file(input_path: str, headers: Optional[Dict] = None) -> Dict:
     """Fetches and parses a JSON file from a URL or local file.
 
     Given a URL or local file path to a JSON file, this function fetches the file,
@@ -184,3 +190,90 @@ def link_request(
     else:
         initial_message["request_invalid"].append(link["href"])
         initial_message["format_invalid"].append(link["href"])
+
+
+def fetch_remote_schema(uri: str) -> dict:
+    """
+    Fetch a remote schema from a URI.
+
+    Args:
+        uri (str): The URI of the schema to fetch.
+
+    Returns:
+        dict: The fetched schema content as a dictionary.
+
+    Raises:
+        requests.RequestException: If the request to fetch the schema fails.
+    """
+    response = requests.get(uri)
+    response.raise_for_status()
+    return response.json()
+
+
+@to_cached_resource()  # type: ignore
+def cached_retrieve(uri: URI) -> str:
+    """
+    Retrieve and cache a remote schema.
+
+    Args:
+        uri (str): The URI of the schema.
+
+    Returns:
+        str: The raw JSON string of the schema.
+
+    Raises:
+        requests.RequestException: If the request to fetch the schema fails.
+        Exception: For any other unexpected errors.
+    """
+    try:
+        response = requests.get(uri, timeout=10)  # Set a timeout for robustness
+        response.raise_for_status()  # Raise an error for HTTP response codes >= 400
+        return response.text
+    except requests.exceptions.RequestException as e:
+        raise requests.RequestException(
+            f"Failed to fetch schema from {uri}: {str(e)}"
+        ) from e
+    except Exception as e:
+        raise Exception(
+            f"Unexpected error while retrieving schema from {uri}: {str(e)}"
+        ) from e
+
+
+def validate_with_ref_resolver(schema_path: str, content: dict) -> None:
+    """
+    Validate a JSON document against a JSON Schema with dynamic reference resolution.
+
+    Args:
+        schema_path (str): Path or URI of the JSON Schema.
+        content (dict): JSON content to validate.
+
+    Raises:
+        jsonschema.exceptions.ValidationError: If validation fails.
+        requests.RequestException: If fetching a remote schema fails.
+        FileNotFoundError: If a local schema file is not found.
+        Exception: If any other error occurs during validation.
+    """
+    # Load the schema
+    if schema_path.startswith("http"):
+        schema = fetch_remote_schema(schema_path)
+    else:
+        try:
+            with open(schema_path, "r") as f:
+                schema = json.load(f)
+        except FileNotFoundError as e:
+            raise FileNotFoundError(f"Schema file not found: {schema_path}") from e
+
+    # Set up the resource and registry for schema resolution
+    resource: Resource = Resource(contents=schema, specification=DRAFT202012)  # type: ignore
+    registry: Registry = Registry(retrieve=cached_retrieve).with_resource(  # type: ignore
+        uri=schema_path, resource=resource
+    )  # type: ignore
+
+    # Validate the content against the schema
+    try:
+        validator = Draft202012Validator(schema, registry=registry)
+        validator.validate(content)
+    except jsonschema.exceptions.ValidationError as e:
+        raise jsonschema.exceptions.ValidationError(f"{e.message}") from e
+    except Exception as e:
+        raise Exception(f"Unexpected error during validation: {str(e)}") from e
diff --git a/stac_validator/validate.py b/stac_validator/validate.py
index 030a1cf..ba71a7f 100644
--- a/stac_validator/validate.py
+++ b/stac_validator/validate.py
@@ -6,7 +6,6 @@ from urllib.error import HTTPError, URLError
 
 import click  # type: ignore
 import jsonschema  # type: ignore
-from jsonschema.validators import validator_for
 from requests import exceptions  # type: ignore
 
 from .utilities import (
@@ -16,6 +15,7 @@ from .utilities import (
     is_valid_url,
     link_request,
     set_schema_addr,
+    validate_with_ref_resolver,
 )
 
 
@@ -86,6 +86,16 @@ class StacValidate:
         self.log = log
 
     def create_err_msg(self, err_type: str, err_msg: str) -> Dict:
+        """
+        Create a standardized error message dictionary and mark validation as failed.
+
+        Args:
+            err_type (str): The type of error.
+            err_msg (str): The error message.
+
+        Returns:
+            dict: Dictionary containing error information.
+        """
         self.valid = False
         return {
             "version": self.version,
@@ -96,11 +106,17 @@ class StacValidate:
             "error_message": err_msg,
         }
 
-    def create_links_message(self):
-        format_valid = []
-        format_invalid = []
-        request_valid = []
-        request_invalid = []
+    def create_links_message(self) -> Dict:
+        """
+        Create an initial links validation message structure.
+
+        Returns:
+            dict: An empty validation structure for link checking.
+        """
+        format_valid: List = []
+        format_invalid: List = []
+        request_valid: List = []
+        request_invalid: List = []
         return {
             "format_valid": format_valid,
             "format_invalid": format_invalid,
@@ -109,6 +125,16 @@ class StacValidate:
         }
 
     def create_message(self, stac_type: str, val_type: str) -> Dict:
+        """
+        Create a standardized validation message dictionary.
+
+        Args:
+            stac_type (str): The STAC object type.
+            val_type (str): The type of validation (e.g., "default", "core").
+
+        Returns:
+            dict: Dictionary containing general validation information.
+        """
         return {
             "version": self.version,
             "path": self.stac_file,
@@ -119,10 +145,11 @@ class StacValidate:
         }
 
     def assets_validator(self) -> Dict:
-        """Validate assets.
+        """
+        Validate the 'assets' field in STAC content if present.
 
         Returns:
-            A dictionary containing the asset validation results.
+            dict: A dictionary containing the asset validation results.
         """
         initial_message = self.create_links_message()
         assets = self.stac_content.get("assets")
@@ -134,19 +161,23 @@ class StacValidate:
         return initial_message
 
     def links_validator(self) -> Dict:
-        """Validate links.
+        """
+        Validate the 'links' field in STAC content.
 
         Returns:
-            A dictionary containing the link validation results.
+            dict: A dictionary containing the link validation results.
         """
         initial_message = self.create_links_message()
-        # get root_url for checking relative links
         root_url = ""
+
+        # Try to locate a self/alternate link that is a valid URL for root reference
         for link in self.stac_content["links"]:
             if link["rel"] in ["self", "alternate"] and is_valid_url(link["href"]):
                 root_url = (
                     link["href"].split("/")[0] + "//" + link["href"].split("/")[2]
                 )
+
+        # Validate each link, making it absolute if necessary
         for link in self.stac_content["links"]:
             if not is_valid_url(link["href"]):
                 link["href"] = root_url + link["href"][1:]
@@ -154,48 +185,86 @@ class StacValidate:
 
         return initial_message
 
+    def custom_validator(self) -> None:
+        """
+        Validate a STAC JSON file against a custom or dynamically resolved JSON schema.
+
+        1. If `self.schema` is a valid URL, fetch and validate.
+        2. If it is a local file path, use it.
+        3. Otherwise, assume it is a relative path and resolve relative to the STAC file.
+
+        Returns:
+            None
+        """
+        if is_valid_url(self.schema):
+            validate_with_ref_resolver(self.schema, self.stac_content)
+        elif os.path.exists(self.schema):
+            validate_with_ref_resolver(self.schema, self.stac_content)
+        else:
+            file_directory = os.path.dirname(os.path.abspath(str(self.stac_file)))
+            self.schema = os.path.join(file_directory, self.schema)
+            self.schema = os.path.abspath(os.path.realpath(self.schema))
+            validate_with_ref_resolver(self.schema, self.stac_content)
+
+    def core_validator(self, stac_type: str) -> None:
+        """
+        Validate the STAC content against the core schema determined by stac_type and version.
+
+        Args:
+            stac_type (str): The type of the STAC object (e.g., "item", "collection").
+        """
+        stac_type = stac_type.lower()
+        self.schema = set_schema_addr(self.version, stac_type)
+        validate_with_ref_resolver(self.schema, self.stac_content)
+
     def extensions_validator(self, stac_type: str) -> Dict:
-        """Validate the STAC extensions according to their corresponding JSON schemas.
+        """
+        Validate STAC extensions for an ITEM or validate the core schema for a COLLECTION.
 
         Args:
             stac_type (str): The STAC object type ("ITEM" or "COLLECTION").
 
         Returns:
-            dict: A dictionary containing validation results.
-
-        Raises:
-            JSONSchemaValidationError: If there is a validation error in the JSON schema.
-            Exception: If there is an error in the STAC extension validation process.
+            dict: A dictionary containing extension (or core) validation results.
         """
         message = self.create_message(stac_type, "extensions")
         message["schema"] = []
         valid = True
+
         if stac_type == "ITEM":
             try:
                 if "stac_extensions" in self.stac_content:
-                    # error with the 'proj' extension not being 'projection' in older stac
+                    # Handle legacy "proj" to "projection" mapping
                     if "proj" in self.stac_content["stac_extensions"]:
                         index = self.stac_content["stac_extensions"].index("proj")
                         self.stac_content["stac_extensions"][index] = "projection"
+
                     schemas = self.stac_content["stac_extensions"]
                     for extension in schemas:
                         if not (is_valid_url(extension) or extension.endswith(".json")):
-                            # where are the extensions for 1.0.0-beta.2 on cdn.staclint.com?
                             if self.version == "1.0.0-beta.2":
                                 self.stac_content["stac_version"] = "1.0.0-beta.1"
                                 self.version = self.stac_content["stac_version"]
-                            extension = f"https://cdn.staclint.com/v{self.version}/extension/{extension}.json"
+                            extension = (
+                                f"https://cdn.staclint.com/v{self.version}/extension/"
+                                f"{extension}.json"
+                            )
                         self.schema = extension
                         self.custom_validator()
                         message["schema"].append(extension)
+
             except jsonschema.exceptions.ValidationError as e:
                 valid = False
                 if e.absolute_path:
-                    err_msg = f"{e.message}. Error is in {' -> '.join([str(i) for i in e.absolute_path])}"
+                    err_msg = (
+                        f"{e.message}. Error is in "
+                        f"{' -> '.join(map(str, e.absolute_path))}"
+                    )
                 else:
-                    err_msg = f"{e.message} of the root of the STAC object"
+                    err_msg = f"{e.message}"
                 message = self.create_err_msg("JSONSchemaValidationError", err_msg)
                 return message
+
             except Exception as e:
                 valid = False
                 err_msg = f"{e}. Error in Extensions."
@@ -203,128 +272,77 @@ class StacValidate:
         else:
             self.core_validator(stac_type)
             message["schema"] = [self.schema]
+
         self.valid = valid
         return message
 
-    def custom_validator(self) -> None:
-        """Validates a STAC JSON file against a JSON schema, which may be located
-        either online or locally.
-
-        The function checks whether the provided schema URL is valid and can be
-        fetched and parsed. If the schema is hosted online, the function uses the
-        fetched schema to validate the STAC JSON file. If the schema is local, the
-        function resolves any references in the schema and then validates the STAC
-        JSON file against the resolved schema. If the schema is specified as a
-        relative path, the function resolves the path relative to the STAC JSON file
-        being validated and uses the resolved schema to validate the STAC JSON file.
-
-        Returns:
-            None
-        """
-        # if schema is hosted online
-        if is_valid_url(self.schema):
-            schema = fetch_and_parse_schema(self.schema)
-            jsonschema.validate(self.stac_content, schema)
-        # in case the path to a json schema is local
-        elif os.path.exists(self.schema):
-            schema_dict = fetch_and_parse_schema(self.schema)
-            # determine the appropriate validator class for the schema
-            ValidatorClass = validator_for(schema_dict)
-            validator = ValidatorClass(schema_dict)
-            # validate the content
-            validator.validate(self.stac_content)
-
-        # deal with a relative path in the schema
-        else:
-            file_directory = os.path.dirname(os.path.abspath(str(self.stac_file)))
-            self.schema = os.path.join(str(file_directory), self.schema)
-            self.schema = os.path.abspath(os.path.realpath(self.schema))
-            schema = fetch_and_parse_schema(self.schema)
-            jsonschema.validate(self.stac_content, schema)
-
-    def core_validator(self, stac_type: str) -> None:
-        """Validate the STAC item or collection against the appropriate JSON schema.
-
-        Args:
-            stac_type (str): The type of STAC object being validated (either "item" or "collection").
-
-        Returns:
-            None
-
-        Raises:
-            ValidationError: If the STAC object fails to validate against the JSON schema.
-
-        The function first determines the appropriate JSON schema to use based on the STAC object's type and version.
-        If the version is one of the specified versions (0.8.0, 0.9.0, 1.0.0, 1.0.0-beta.1, 1.0.0-beta.2, or 1.0.0-rc.2),
-        it uses the corresponding schema stored locally. Otherwise, it retrieves the schema from the appropriate URL
-        using the `set_schema_addr` function. The function then calls the `custom_validator` method to validate the
-        STAC object against the schema.
-        """
-        stac_type = stac_type.lower()
-        self.schema = set_schema_addr(self.version, stac_type)
-        self.custom_validator()
-
     def default_validator(self, stac_type: str) -> Dict:
-        """Validate the STAC catalog or item against the core schema and its extensions.
+        """
+        Validate a STAC Catalog or Item against the core schema and its extensions.
 
         Args:
-            stac_type (str): The type of STAC object being validated. Must be either "catalog" or "item".
+            stac_type (str): The type of STAC object. Must be "catalog" or "item".
 
         Returns:
-            A dictionary containing the results of the default validation, including whether the STAC object is valid,
-            any validation errors encountered, and any links and assets that were validated.
+            dict: A dictionary with results of the default validation.
         """
         message = self.create_message(stac_type, "default")
         message["schema"] = []
+
+        # Validate core
         self.core_validator(stac_type)
         core_schema = self.schema
         message["schema"].append(core_schema)
-        stac_type = stac_type.upper()
-        if stac_type == "ITEM":
-            message = self.extensions_validator(stac_type)
+        stac_upper = stac_type.upper()
+
+        # Validate extensions if ITEM
+        if stac_upper == "ITEM":
+            message = self.extensions_validator(stac_upper)
             message["validation_method"] = "default"
             message["schema"].append(core_schema)
+
+        # Optionally validate links
         if self.links:
             message["links_validated"] = self.links_validator()
+
+        # Optionally validate assets
         if self.assets:
             message["assets_validated"] = self.assets_validator()
+
         return message
 
     def recursive_validator(self, stac_type: str) -> bool:
-        """Recursively validate a STAC JSON document against its JSON Schema.
+        """
+        Recursively validate a STAC JSON document and its children/items.
 
-        This method validates a STAC JSON document recursively against its JSON Schema by following its "child" and "item" links.
-        It uses the `default_validator` and `fetch_and_parse_file` functions to validate the current STAC document and retrieve the
-        next one to be validated, respectively.
+        Follows "child" and "item" links, calling `default_validator` on each.
 
         Args:
-            self: An instance of the STACValidator class.
-            stac_type: A string representing the STAC object type to validate.
+            stac_type (str): The STAC object type to validate.
 
         Returns:
-            A boolean indicating whether the validation was successful.
-
-        Raises:
-            jsonschema.exceptions.ValidationError: If the STAC document does not validate against its JSON Schema.
-
+            bool: True if all validations are successful, False otherwise.
         """
-        if self.skip_val is False:
+        if not self.skip_val:
             self.schema = set_schema_addr(self.version, stac_type.lower())
             message = self.create_message(stac_type, "recursive")
             message["valid_stac"] = False
+
             try:
                 _ = self.default_validator(stac_type)
-
             except jsonschema.exceptions.ValidationError as e:
                 if e.absolute_path:
-                    err_msg = f"{e.message}. Error is in {' -> '.join([str(i) for i in e.absolute_path])}"
+                    err_msg = (
+                        f"{e.message}. Error is in "
+                        f"{' -> '.join([str(i) for i in e.absolute_path])}"
+                    )
                 else:
-                    err_msg = f"{e.message} of the root of the STAC object"
+                    err_msg = f"{e.message}"
                 message.update(
                     self.create_err_msg("JSONSchemaValidationError", err_msg)
                 )
                 self.message.append(message)
-                if self.verbose is True:
+                if self.verbose:
                     click.echo(json.dumps(message, indent=4))
                 return False
 
@@ -332,24 +350,26 @@ class StacValidate:
             self.message.append(message)
             if self.verbose:
                 click.echo(json.dumps(message, indent=4))
+
             self.depth += 1
             if self.max_depth and self.depth >= self.max_depth:
                 self.skip_val = True
+
             base_url = self.stac_file
 
             for link in self.stac_content["links"]:
-                if link["rel"] == "child" or link["rel"] == "item":
+                if link["rel"] in ("child", "item"):
                     address = link["href"]
                     if not is_valid_url(address):
-                        x = str(base_url).split("/")
-                        x.pop(-1)
-                        st = x[0]
-                        for i in range(len(x)):
-                            if i > 0:
-                                st = st + "/" + x[i]
-                        self.stac_file = st + "/" + address
+                        path_parts = str(base_url).split("/")
+                        path_parts.pop(-1)
+                        root = path_parts[0]
+                        for i in range(1, len(path_parts)):
+                            root = f"{root}/{path_parts[i]}"
+                        self.stac_file = f"{root}/{address}"
                     else:
                         self.stac_file = address
+
                     self.stac_content = fetch_and_parse_file(
                         str(self.stac_file), self.headers
                     )
@@ -364,7 +384,7 @@ class StacValidate:
                     message = self.create_message(stac_type, "recursive")
                     if self.version == "0.7.0":
                         schema = fetch_and_parse_schema(self.schema)
-                        # this next line prevents this: unknown url type: 'geojson.json' ??
+                        # Prevent unknown url type issue
                         schema["allOf"] = [{}]
                         jsonschema.validate(self.stac_content, schema)
                     else:
@@ -372,54 +392,45 @@ class StacValidate:
                         message["schema"] = msg["schema"]
                     message["valid_stac"] = True
 
-                    if self.log != "":
+                    if self.log:
                         self.message.append(message)
-                    if (
-                        not self.max_depth or self.max_depth < 5
-                    ):  # TODO this should be configurable, correct?
+                    if not self.max_depth or self.max_depth < 5:
                         self.message.append(message)
+
         return True
 
-    def validate_dict(self, stac_content) -> bool:
-        """Validate the contents of a dictionary representing a STAC object.
+    def validate_dict(self, stac_content: Dict) -> bool:
+        """
+        Validate the contents of a dictionary representing a STAC object.
 
         Args:
-            stac_content (dict): The dictionary representation of the STAC object to validate.
+            stac_content (dict): The dictionary representation of the STAC object.
 
         Returns:
-            A bool indicating if validation was successfull.
+            bool: True if validation succeeded, False otherwise.
         """
         self.stac_content = stac_content
         return self.run()
 
     def validate_item_collection_dict(self, item_collection: Dict) -> None:
-        """Validate the contents of an item collection.
+        """
+        Validate the contents of a STAC Item Collection.
 
         Args:
-            item_collection (dict): The dictionary representation of the item collection to validate.
-
-        Returns:
-            None
+            item_collection (dict): The dictionary representation of the item collection.
         """
         for item in item_collection["features"]:
             self.schema = ""
             self.validate_dict(item)
 
     def validate_collections(self) -> None:
-        """ "Validate STAC collections from a /collections endpoint.
+        """
+        Validate STAC Collections from a /collections endpoint.
 
         Raises:
-            URLError: If there is an issue with the URL used to fetch the item collection.
-            JSONDecodeError: If the item collection content cannot be parsed as JSON.
-            ValueError: If the item collection does not conform to the STAC specification.
-            TypeError: If the item collection content is not a dictionary or JSON object.
-            FileNotFoundError: If the item collection file cannot be found.
-            ConnectionError: If there is an issue with the internet connection used to fetch the item collection.
-            exceptions.SSLError: If there is an issue with the SSL connection used to fetch the item collection.
-            OSError: If there is an issue with the file system (e.g., read/write permissions) while trying to write to the log file.
-
-        Returns:
-            None
+            URLError, JSONDecodeError, ValueError, TypeError, FileNotFoundError,
+            ConnectionError, exceptions.SSLError, OSError: Various errors related
+            to fetching or parsing.
         """
         collections = fetch_and_parse_file(str(self.stac_file), self.headers)
         for collection in collections["collections"]:
@@ -427,32 +438,26 @@ class StacValidate:
             self.validate_dict(collection)
 
     def validate_item_collection(self) -> None:
-        """Validate a STAC item collection.
+        """
+        Validate a STAC Item Collection with optional pagination.
 
         Raises:
-            URLError: If there is an issue with the URL used to fetch the item collection.
-            JSONDecodeError: If the item collection content cannot be parsed as JSON.
-            ValueError: If the item collection does not conform to the STAC specification.
-            TypeError: If the item collection content is not a dictionary or JSON object.
-            FileNotFoundError: If the item collection file cannot be found.
-            ConnectionError: If there is an issue with the internet connection used to fetch the item collection.
-            exceptions.SSLError: If there is an issue with the SSL connection used to fetch the item collection.
-            OSError: If there is an issue with the file system (e.g., read/write permissions) while trying to write to the log file.
-
-        Returns:
-            None
+            URLError, JSONDecodeError, ValueError, TypeError, FileNotFoundError,
+            ConnectionError, exceptions.SSLError, OSError: Various errors related
+            to fetching or parsing.
         """
         page = 1
         print(f"processing page {page}")
         item_collection = fetch_and_parse_file(str(self.stac_file), self.headers)
         self.validate_item_collection_dict(item_collection)
+
         try:
             if self.pages is not None:
                 for _ in range(self.pages - 1):
                     if "links" in item_collection:
                         for link in item_collection["links"]:
                             if link["rel"] == "next":
-                                page = page + 1
+                                page += 1
                                 print(f"processing page {page}")
                                 next_link = link["href"]
                                 self.stac_file = next_link
@@ -462,35 +467,29 @@ class StacValidate:
                                 self.validate_item_collection_dict(item_collection)
                                 break
         except Exception as e:
-            message = {}
-            message["pagination_error"] = (
-                f"Validating the item collection failed on page {page}: {str(e)}"
-            )
+            message = {
+                "pagination_error": (
+                    f"Validating the item collection failed on page {page}: {str(e)}"
+                )
+            }
             self.message.append(message)
 
     def run(self) -> bool:
-        """Runs the STAC validation process based on the input parameters.
+        """
+        Run the STAC validation process based on the input parameters.
 
         Returns:
             bool: True if the STAC is valid, False otherwise.
 
         Raises:
-            URLError: If there is an error with the URL.
-            JSONDecodeError: If there is an error decoding the JSON content.
-            ValueError: If there is an invalid value.
-            TypeError: If there is an invalid type.
-            FileNotFoundError: If the file is not found.
-            ConnectionError: If there is an error with the connection.
-            exceptions.SSLError: If there is an SSL error.
-            OSError: If there is an error with the operating system.
-            jsonschema.exceptions.ValidationError: If the STAC content fails validation.
-            KeyError: If the specified key is not found.
-            HTTPError: If there is an error with the HTTP connection.
-            Exception: If there is any other type of error.
-
+            URLError, JSONDecodeError, ValueError, TypeError, FileNotFoundError,
+            ConnectionError, exceptions.SSLError, OSError, KeyError, HTTPError,
+            jsonschema.exceptions.ValidationError, Exception: Various errors
+            during fetching or parsing.
         """
         message = {}
         try:
+            # Fetch STAC content if not provided via item_collection/collections
             if (
                 self.stac_file is not None
                 and not self.item_collection
@@ -506,24 +505,31 @@ class StacValidate:
                 self.core_validator(stac_type)
                 message["schema"] = [self.schema]
                 self.valid = True
-            elif self.schema != "":
+
+            elif self.schema:
                 message = self.create_message(stac_type, "custom")
                 message["schema"] = [self.schema]
                 self.custom_validator()
                 self.valid = True
+
             elif self.recursive:
                 self.valid = self.recursive_validator(stac_type)
+
             elif self.extensions:
                 message = self.extensions_validator(stac_type)
+
             else:
                 self.valid = True
                 message = self.default_validator(stac_type)
 
         except jsonschema.exceptions.ValidationError as e:
             if e.absolute_path:
-                err_msg = f"{e.message}. Error is in {' -> '.join([str(i) for i in e.absolute_path])} "
+                err_msg = (
+                    f"{e.message}. Error is in "
+                    f"{' -> '.join([str(i) for i in e.absolute_path])} "
+                )
             else:
-                err_msg = f"{e.message} of the root of the STAC object"
+                err_msg = f"{e.message}"
             message.update(self.create_err_msg("JSONSchemaValidationError", err_msg))
 
         except (
@@ -547,7 +553,8 @@ class StacValidate:
             message["valid_stac"] = self.valid
             self.message.append(message)
 
-        if self.log != "":
+        # Write out log if path is provided
+        if self.log:
             with open(self.log, "w") as f:
                 f.write(json.dumps(self.message, indent=4))
 


Test patch:
diff --git a/tests/test_assets.py b/tests/test_assets.py
index 8ac2f82..d8c4700 100644
--- a/tests/test_assets.py
+++ b/tests/test_assets.py
@@ -22,7 +22,7 @@ def test_assets_v090():
             ],
             "valid_stac": False,
             "error_type": "JSONSchemaValidationError",
-            "error_message": "-0.00751271 is less than the minimum of 0. Error is in properties -> view:off_nadir",
+            "error_message": "-0.00751271 is less than the minimum of 0",
             "validation_method": "default",
             "assets_validated": {
                 "format_valid": [
diff --git a/tests/test_core.py b/tests/test_core.py
index 69d68c7..0c6c252 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -83,7 +83,7 @@ def test_core_bad_item_local_v090():
             "schema": ["https://cdn.staclint.com/v0.9.0/item.json"],
             "valid_stac": False,
             "error_type": "JSONSchemaValidationError",
-            "error_message": "'id' is a required property of the root of the STAC object",
+            "error_message": "'id' is a required property",
         }
     ]
 
diff --git a/tests/test_custom.py b/tests/test_custom.py
index 9486c4a..bc19de0 100644
--- a/tests/test_custom.py
+++ b/tests/test_custom.py
@@ -20,7 +20,7 @@ def test_custom_item_remote_schema_v080():
             "validation_method": "custom",
             "valid_stac": False,
             "error_type": "JSONSchemaValidationError",
-            "error_message": "'bbox' is a required property of the root of the STAC object",
+            "error_message": "'bbox' is a required property",
         }
     ]
 
@@ -74,7 +74,7 @@ def test_custom_bad_item_remote_schema_v090():
             "schema": ["https://cdn.staclint.com/v0.9.0/item.json"],
             "valid_stac": False,
             "error_type": "JSONSchemaValidationError",
-            "error_message": "'id' is a required property of the root of the STAC object",
+            "error_message": "'id' is a required property",
         }
     ]
 
@@ -99,29 +99,6 @@ def test_custom_item_remote_schema_v1rc2():
     ]
 
 
-def test_custom_proj_error_v1rc2():
-    schema = "https://stac-extensions.github.io/projection/v1.0.0/schema.json"
-    stac_file = (
-        "tests/test_data/1rc2/extensions-collection/./proj-example/proj-example.json"
-    )
-    stac = stac_validator.StacValidate(stac_file, custom=schema)
-    stac.run()
-    assert stac.message == [
-        {
-            "version": "1.0.0-rc.2",
-            "path": "tests/test_data/1rc2/extensions-collection/./proj-example/proj-example.json",
-            "schema": [
-                "https://stac-extensions.github.io/projection/v1.0.0/schema.json"
-            ],
-            "valid_stac": False,
-            "asset_type": "ITEM",
-            "validation_method": "custom",
-            "error_type": "JSONSchemaValidationError",
-            "error_message": "'A' is not of type 'number'. Error is in properties -> proj:centroid -> lat ",
-        }
-    ]
-
-
 def test_custom_item_v100_relative_schema():
     schema = "../schema/v1.0.0/projection.json"
     stac_file = "tests/test_data/v100/extended-item-no-extensions.json"
diff --git a/tests/test_links.py b/tests/test_links.py
index 1ca99fc..6e97e2c 100644
--- a/tests/test_links.py
+++ b/tests/test_links.py
@@ -20,7 +20,7 @@ def test_poorly_formatted_v090():
             ],
             "valid_stac": False,
             "error_type": "JSONSchemaValidationError",
-            "error_message": "-0.00751271 is less than the minimum of 0. Error is in properties -> view:off_nadir",
+            "error_message": "-0.00751271 is less than the minimum of 0",
             "validation_method": "default",
             "links_validated": {
                 "format_valid": [
diff --git a/tests/test_recursion.py b/tests/test_recursion.py
index 1579bd0..ba49c4a 100644
--- a/tests/test_recursion.py
+++ b/tests/test_recursion.py
@@ -328,7 +328,7 @@ def test_recursion_with_bad_item():
             ],
             "valid_stac": False,
             "error_type": "JSONSchemaValidationError",
-            "error_message": "'id' is a required property of the root of the STAC object",
+            "error_message": "'id' is a required property",
         },
     ]
 
@@ -350,6 +350,6 @@ def test_recursion_with_missing_collection_link():
             "valid_stac": False,
             "validation_method": "recursive",
             "error_type": "JSONSchemaValidationError",
-            "error_message": "'simple-collection' should not be valid under {}. Error is in collection",
+            "error_message": "'simple-collection' should not be valid under {}",
         },
     ]