diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1d89ac3d..32651036 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -21,11 +21,11 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine + pip install setuptools wheel twine build - name: Build package run: | - python setup.py sdist bdist_wheel + python -m build - name: Publish package to PyPI env: diff --git a/.github/workflows/test-runner.yml b/.github/workflows/test-runner.yml index 7be3c3ed..e56a5250 100644 --- a/.github/workflows/test-runner.yml +++ b/.github/workflows/test-runner.yml @@ -29,7 +29,7 @@ jobs: if: matrix.python-version == '3.12' run: | pip install . - pip install -r requirements-dev.txt + pip install -e .[dev] mypy stac_validator/ - name: Run pre-commit diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b1dfa43..81a36d70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is (loosely) based on [Keep a Changelog](http://keepachangelog.com/) ## [Unreleased] +## [v3.10.2] - 2025-11-16 + +### Fixed +- Added validation for STAC version format to provide clear error messages when stac_version field is missing, empty, or incorrectly formatted (e.g., "1.1" instead of "1.1.0"). [#268](https://github.com/stac-utils/stac-validator/pull/268) + +### Changed +- Migrated from `setup.py` to modern `pyproject.toml` packaging (PEP 621), removing legacy `requirements-dev.txt` file. [#268](https://github.com/stac-utils/stac-validator/pull/268) + ## [v3.10.1] - 2025-07-26 ### Fixed @@ -302,7 +310,8 @@ The format is (loosely) based on [Keep a Changelog](http://keepachangelog.com/) - With the newest version - 1.0.0-beta.2 - items will run through jsonchema validation before the PySTAC validation. The reason for this is that jsonschema will give more informative error messages. This should be addressed better in the future. This is not the case with the --recursive option as time can be a concern here with larger collections. - Logging. Various additions were made here depending on the options selected. This was done to help assist people to update their STAC collections. -[Unreleased]: https://github.com/sparkgeo/stac-validator/compare/v3.10.1..main +[Unreleased]: https://github.com/sparkgeo/stac-validator/compare/v3.10.2..main +[v3.10.2]: https://github.com/sparkgeo/stac-validator/compare/v3.10.1..v3.10.2 [v3.10.1]: https://github.com/sparkgeo/stac-validator/compare/v3.10.0..v3.10.1 [v3.10.0]: https://github.com/sparkgeo/stac-validator/compare/v3.9.3..v3.10.0 [v3.9.3]: https://github.com/sparkgeo/stac-validator/compare/v3.9.2..v3.9.3 diff --git a/pyproject.toml b/pyproject.toml index b75f5c12..fcc16424 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,62 @@ [build-system] -requires = [ - "requests", - "jsonschema", - "click", - "setuptools" +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "stac_validator" +version = "3.10.2" +description = "A package to validate STAC files" +authors = [ + {name = "James Banting"}, + {name = "Jonathan Healy", email = "jonathan.d.healy@gmail.com"} ] -build-backend = "setuptools.build_meta" \ No newline at end of file +license = {text = "Apache-2.0"} +classifiers = [ + "Intended Audience :: Information Technology", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.8", + "Topic :: Scientific/Engineering :: GIS", +] +keywords = ["STAC", "validation", "raster"] +requires-python = ">=3.8" +dependencies = [ + "requests>=2.32.3", + "jsonschema>=4.23.0", + "click>=8.1.8", + "referencing>=0.35.1", + "pyYAML>=6.0.1", +] +optional-dependencies.dev = [ + "black", + "pytest", + "pytest-mypy", + "pre-commit", + "requests-mock", + "types-setuptools", + "stac-pydantic>=3.3.0", + "mypy", + "types-attrs", + "types-requests", + "types-jsonschema" +] +optional-dependencies.pydantic = [ + "stac-pydantic>=3.3.0" +] + +[project.urls] +Homepage = "https://github.com/stac-utils/stac-validator" +Repository = "https://github.com/stac-utils/stac-validator" + +[project.readme] +file = "README.md" +content-type = "text/markdown" + +[tool.setuptools] +packages = ["stac_validator"] + +[project.scripts] +stac-validator = "stac_validator.stac_validator:main" + +[tool.setuptools.package-data] +stac_validator = ["*.yaml"] \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index aec90522..00000000 --- a/requirements-dev.txt +++ /dev/null @@ -1,6 +0,0 @@ -black -pytest -pytest-mypy -pre-commit -requests-mock -types-jsonschema diff --git a/setup.py b/setup.py deleted file mode 100644 index d3d28184..00000000 --- a/setup.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python - -from setuptools import setup - -__version__ = "3.10.1" - -with open("README.md", "r") as fh: - long_description = fh.read() - -setup( - name="stac_validator", - version=__version__, - author="James Banting, Jonathan Healy", - author_email="jonathan.d.healy@gmail.com", - description="A package to validate STAC files", - license="Apache-2.0", - classifiers=[ - "Intended Audience :: Information Technology", - "Intended Audience :: Science/Research", - "License :: OSI Approved :: Apache Software License", - "Programming Language :: Python :: 3.8", - "Topic :: Scientific/Engineering :: GIS", - ], - keywords="STAC validation raster", - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/stac-utils/stac-validator", - install_requires=[ - "requests>=2.32.3", - "jsonschema>=4.23.0", - "click>=8.1.8", - "referencing>=0.35.1", - "pyYAML>=6.0.1", - ], - extras_require={ - "dev": ["pytest", "requests-mock", "types-setuptools", "stac-pydantic>=3.3.0"], - "pydantic": [ - "stac-pydantic>=3.3.0", - ], - }, - packages=["stac_validator"], - entry_points={ - "console_scripts": ["stac-validator = stac_validator.stac_validator:main"] - }, - python_requires=">=3.8", - tests_require=["pytest", "requests-mock"], -) diff --git a/stac_validator/utilities.py b/stac_validator/utilities.py index c1baf60c..40cbfeeb 100644 --- a/stac_validator/utilities.py +++ b/stac_validator/utilities.py @@ -2,7 +2,7 @@ import json import os import ssl -from typing import Dict, Optional +from typing import Dict, Optional, Tuple from urllib.parse import urlparse from urllib.request import Request, urlopen @@ -25,6 +25,71 @@ ] +def validate_stac_version_field(stac_content: Dict) -> Tuple[bool, str, str]: + """Validate the stac_version field in STAC content. + + Args: + stac_content (dict): The STAC content dictionary. + + Returns: + Tuple[bool, str, str]: (is_valid, error_type, error_message) + - is_valid: True if the version is valid + - error_type: Error type string if invalid, empty string if valid + - error_message: Error message if invalid, empty string if valid + """ + version = stac_content.get("stac_version", "") + + # Check if version is present and not empty + if not version or not isinstance(version, str) or version.strip() == "": + error_type = "MissingSTACVersion" + error_msg = ( + "The 'stac_version' field is missing or empty. " + "Please ensure your STAC object includes a valid 'stac_version' field " + "(e.g., '1.0.0', '1.1.0'). This field is required for proper schema validation." + ) + return False, error_type, error_msg + + # Validate version format + format_valid, format_error = validate_version_format(version) + if not format_valid: + return False, "InvalidSTACVersionFormat", format_error + + return True, "", "" + + +def validate_version_format(version: str) -> Tuple[bool, str]: + """Validate that a STAC version string has the correct format. + + Args: + version (str): The version string to validate. + + Returns: + Tuple[bool, str]: (is_valid, error_message) + - is_valid: True if the version format is valid + - error_message: Description of the issue if invalid, empty string if valid + + Valid formats: + - Standard semver: "1.0.0", "1.1.0", "0.9.0" + - Pre-release versions: "1.0.0-beta.1", "1.0.0-rc.1" + """ + if not version: + return False, "Version is empty" + + import re + + # Regex for semantic versioning: major.minor.patch with optional pre-release + semver_pattern = r"^\d+\.\d+\.\d+(-[\w\.\-]+)?$" + + if not re.match(semver_pattern, version): + return False, ( + f"Version '{version}' does not match expected format. " + "STAC versions should be in semantic versioning format (e.g., '1.0.0', '1.1.0', '1.0.0-beta.1'). " + "Please check your 'stac_version' field." + ) + + return True, "" + + def is_url(url: str) -> bool: """Checks whether the input string is a valid URL. diff --git a/stac_validator/validate.py b/stac_validator/validate.py index 0c68cec8..fd59a97e 100644 --- a/stac_validator/validate.py +++ b/stac_validator/validate.py @@ -18,6 +18,7 @@ link_request, load_schema_config, set_schema_addr, + validate_stac_version_field, validate_with_ref_resolver, ) @@ -247,6 +248,7 @@ def create_err_msg( err_msg: str, error_obj: Optional[Exception] = None, schema_uri: str = "", + version: Optional[str] = None, ) -> Dict[str, Union[str, bool, List[str], Dict[str, Any]]]: """ Create a standardized error message dictionary and mark validation as failed. @@ -256,6 +258,7 @@ def create_err_msg( err_msg (str): The error message. error_obj (Optional[Exception]): The raw exception object for verbose details. schema_uri (str, optional): The URI of the schema that failed validation. + version (Optional[str]): Override version to use in the error message. Returns: dict: Dictionary containing error information. @@ -268,9 +271,16 @@ def create_err_msg( if not isinstance(err_msg, str): err_msg = str(err_msg) + # Use provided version or fall back to self.version + version_to_use = ( + version + if version is not None + else (str(self.version) if hasattr(self, "version") else "") + ) + # Initialize the message with common fields message: Dict[str, Any] = { - "version": str(self.version) if hasattr(self, "version") else "", + "version": version_to_use, "path": str(self.stac_file) if hasattr(self, "stac_file") else "", "schema": ( [self._original_schema_paths.get(self.schema, self.schema)] @@ -306,7 +316,7 @@ def create_err_msg( # Initialize the error message with common fields error_message: Dict[str, Union[str, bool, List[str], Dict[str, Any]]] = { - "version": str(self.version) if self.version is not None else "", + "version": version_to_use, "path": str(self.stac_file) if self.stac_file is not None else "", "schema": schema_field, # All schemas that were checked "valid_stac": False, @@ -950,7 +960,25 @@ def run(self) -> bool: self.stac_content = fetch_and_parse_file(self.stac_file, self.headers) stac_type = get_stac_type(self.stac_content).upper() - self.version = self.stac_content["stac_version"] + version = self.stac_content.get("stac_version", "") + + # Validate stac_version field comprehensively + version_valid, version_error_type, version_error_msg = ( + validate_stac_version_field(self.stac_content) + ) + if not version_valid: + message.update( + self.create_err_msg( + err_type=version_error_type, + err_msg=version_error_msg, + schema_uri="", + version=version, # Pass the version we extracted + ) + ) + self.message.append(message) + return self.valid + + self.version = version if self.core: message = self.create_message(stac_type, "core") diff --git a/tests/test_default.py b/tests/test_default.py index 44c6e13c..d1c496e7 100644 --- a/tests/test_default.py +++ b/tests/test_default.py @@ -180,3 +180,105 @@ def test_default_collection_validates_extensions(): "validation_method": "default", } ] + + +def test_missing_stac_version(): + """Test that missing or empty stac_version provides a clear error message.""" + import json + import tempfile + + # Create a test STAC object with empty stac_version + test_stac = { + "type": "Collection", + "id": "test-collection", + "stac_version": "", # Empty stac_version + "description": "Test collection", + "license": "MIT", + "extent": { + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": {"interval": [["2020-01-01T00:00:00Z", None]]}, + }, + "links": [{"rel": "self", "href": "test.json", "type": "application/json"}], + } + + # Write to temp file + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(test_stac, f) + temp_file = f.name + + try: + stac = stac_validator.StacValidate(temp_file) + stac.run() + assert stac.message == [ + { + "version": "", + "path": temp_file, + "schema": [], + "valid_stac": False, + "error_type": "MissingSTACVersion", + "error_message": ( + "The 'stac_version' field is missing or empty. " + "Please ensure your STAC object includes a valid 'stac_version' field " + "(e.g., '1.0.0', '1.1.0'). This field is required for proper schema validation." + ), + "failed_schema": "", + "recommendation": "For more accurate error information, rerun with --verbose.", + } + ] + finally: + import os + + os.unlink(temp_file) + + +def test_invalid_stac_version_format(): + """Test that invalid stac_version format provides a clear error message.""" + import json + import tempfile + + # Test cases for invalid formats + invalid_versions = ["1.1", "1", "1.0", "abc", "1.0.0.0"] + + for invalid_version in invalid_versions: + # Create a test STAC object with invalid stac_version format + test_stac = { + "type": "Collection", + "id": "test-collection", + "stac_version": invalid_version, # Invalid format + "description": "Test collection", + "license": "MIT", + "extent": { + "spatial": {"bbox": [[-180, -90, 180, 90]]}, + "temporal": {"interval": [["2020-01-01T00:00:00Z", None]]}, + }, + "links": [{"rel": "self", "href": "test.json", "type": "application/json"}], + } + + # Write to temp file + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(test_stac, f) + temp_file = f.name + + try: + stac = stac_validator.StacValidate(temp_file) + stac.run() + assert stac.message == [ + { + "version": invalid_version, + "path": temp_file, + "schema": [], + "valid_stac": False, + "error_type": "InvalidSTACVersionFormat", + "error_message": ( + f"Version '{invalid_version}' does not match expected format. " + "STAC versions should be in semantic versioning format (e.g., '1.0.0', '1.1.0', '1.0.0-beta.1'). " + "Please check your 'stac_version' field." + ), + "failed_schema": "", + "recommendation": "For more accurate error information, rerun with --verbose.", + } + ] + finally: + import os + + os.unlink(temp_file)