diff --git a/.github/workflows/test-runner.yml b/.github/workflows/test-runner.yml index b722914..722d8dd 100644 --- a/.github/workflows/test-runner.yml +++ b/.github/workflows/test-runner.yml @@ -18,10 +18,10 @@ jobs: steps: - uses: actions/checkout@v3 - - name: Set up Python 3.10 + - name: Set up Python 3.12 uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.12" - name: Install dependencies run: | @@ -41,7 +41,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e51836..5d1d8cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is (loosely) based on [Keep a Changelog](http://keepachangelog.com/) ### Added +- Added validation for bounding boxes that cross the antimeridian (180°/-180° longitude) ([#121](https://github.com/stac-utils/stac-check/pull/121)) + - Checks that bbox coordinates follow the GeoJSON specification for antimeridian crossing + - Detects and reports cases where a bbox incorrectly "belts the globe" instead of properly crossing the antimeridian + - Provides clear error messages to help users fix incorrectly formatted bboxes - Added sponsors and supporters section with logos ([#122](https://github.com/stac-utils/stac-check/pull/122)) - Added check to verify that bbox matches item's polygon geometry ([#123](https://github.com/stac-utils/stac-check/pull/123)) - Added configuration documentation to README ([#124](https://github.com/stac-utils/stac-check/pull/124)) @@ -17,11 +21,19 @@ The format is (loosely) based on [Keep a Changelog](http://keepachangelog.com/) - Improved bbox validation output to show detailed information about mismatches between bbox and geometry bounds, including which specific coordinates differ and by how much ([#126](https://github.com/stac-utils/stac-check/pull/126)) +### Fixed + +- Fixed collection summaries check incorrectly showing messages for Item assets ([#121](https://github.com/stac-utils/stac-check/pull/127)) + ### Updated - Improved README with table of contents, better formatting, stac-check logo, and enhanced documentation ([#122](https://github.com/stac-utils/stac-check/pull/122)) - Enhanced Contributing guidelines with step-by-step instructions ([#122](https://github.com/stac-utils/stac-check/pull/122)) +### Removed + +- Support for Python 3.8 ([#121](https://github.com/stac-utils/stac-check/pull/121)) + ## [v1.6.0] - 2025-03-14 ### Added diff --git a/README.md b/README.md index 09eb3d0..3c525fb 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,8 @@ linting: links_title: true # Ensure that links in catalogs and collections include self link links_self: true + # check if a bbox that crosses the antimeridian is correctly formatted + check_bbox_antimeridian: true settings: # Number of links before the bloated links warning is shown diff --git a/sample_files/1.0.0/invalid-antimeridian-bbox.json b/sample_files/1.0.0/invalid-antimeridian-bbox.json new file mode 100644 index 0000000..1145984 --- /dev/null +++ b/sample_files/1.0.0/invalid-antimeridian-bbox.json @@ -0,0 +1,23 @@ +{ + "stac_version": "1.0.0", + "type": "Feature", + "id": "invalid-antimeridian-bbox", + "bbox": [-170, -10, 170, 10], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [-170, -10], + [170, -10], + [170, 10], + [-170, 10], + [-170, -10] + ] + ] + }, + "properties": { + "datetime": "2023-01-01T00:00:00Z" + }, + "links": [], +"assets": {} +} \ No newline at end of file diff --git a/setup.py b/setup.py index 5cc2e67..4e95a99 100644 --- a/setup.py +++ b/setup.py @@ -37,6 +37,6 @@ license="MIT", long_description=long_description, long_description_content_type="text/markdown", - python_requires=">=3.8", + python_requires=">=3.9", tests_require=["pytest"], ) diff --git a/stac_check/lint.py b/stac_check/lint.py index 8e11f38..7fbc564 100644 --- a/stac_check/lint.py +++ b/stac_check/lint.py @@ -98,6 +98,9 @@ def check_summaries(self) -> bool: check_searchable_identifiers(self) -> bool: Checks whether the STAC JSON file has searchable identifiers. + check_bbox_antimeridian(self) -> bool: + Checks if a bbox that crosses the antimeridian is correctly formatted. + check_percent_encoded(self) -> bool: Checks whether the STAC JSON file has percent-encoded characters. @@ -194,10 +197,13 @@ def parse_config(config_file: Optional[str] = None) -> Dict: with open(default_config_file) as f: default_config = yaml.load(f, Loader=yaml.FullLoader) else: - with importlib.resources.open_text( - "stac_check", "stac-check.config.yml" - ) as f: - default_config = yaml.load(f, Loader=yaml.FullLoader) + config_file_path = importlib.resources.files("stac_check").joinpath( + "stac-check.config.yml" + ) + with importlib.resources.as_file(config_file_path) as path: + with open(path) as f: + default_config = yaml.load(f, Loader=yaml.FullLoader) + if config_file: with open(config_file) as f: config = yaml.load(f, Loader=yaml.FullLoader) @@ -670,7 +676,11 @@ def create_best_practices_dict(self) -> Dict: best_practices_dict["check_catalog_id"] = [msg_1] # best practices - collections should contain summaries - if self.check_summaries() == False and config["check_summaries"] == True: + if ( + self.asset_type == "COLLECTION" + and self.check_summaries() == False + and config["check_summaries"] == True + ): msg_1 = "A STAC collection should contain a summaries field" msg_2 = "It is recommended to store information like eo:bands in summaries" best_practices_dict["check_summaries"] = [msg_1, msg_2] @@ -783,8 +793,64 @@ def create_best_practices_dict(self) -> Dict: msg_1 = "A link to 'self' in links is strongly recommended" best_practices_dict["check_links_self"] = [msg_1] + # Check if a bbox that crosses the antimeridian is correctly formatted + if not self.check_bbox_antimeridian() and config.get( + "check_bbox_antimeridian", True + ): + # Get the bbox values to include in the error message + bbox = self.data.get("bbox", []) + + if len(bbox) == 4: # 2D bbox [west, south, east, north] + west, _, east, _ = bbox + elif ( + len(bbox) == 6 + ): # 3D bbox [west, south, min_elev, east, north, max_elev] + west, _, _, east, _, _ = bbox + + msg_1 = f"BBox crossing the antimeridian should have west longitude > east longitude (found west={west}, east={east})" + msg_2 = f"Current bbox format appears to be belting the globe instead of properly crossing the antimeridian. Bbox: {bbox}" + + best_practices_dict["check_bbox_antimeridian"] = [msg_1, msg_2] + return best_practices_dict + def check_bbox_antimeridian(self) -> bool: + """ + Checks if a bbox that crosses the antimeridian is correctly formatted. + + According to the GeoJSON spec, when a bbox crosses the antimeridian (180°/-180° longitude), + the minimum longitude (bbox[0]) should be greater than the maximum longitude (bbox[2]). + This method checks if this convention is followed correctly. + + Returns: + bool: True if the bbox is valid (either doesn't cross antimeridian or crosses it correctly), + False if it incorrectly crosses the antimeridian. + """ + if "bbox" not in self.data: + return True + + bbox = self.data["bbox"] + + # Extract the 2D part of the bbox (ignoring elevation if present) + if len(bbox) == 4: # 2D bbox [west, south, east, north] + west, south, east, north = bbox + elif len(bbox) == 6: # 3D bbox [west, south, min_elev, east, north, max_elev] + west, south, _, east, north, _ = bbox + else: + # Invalid bbox format, can't check + return True + + # Check if the bbox appears to cross the antimeridian + # This is the case when west > east in a valid bbox that crosses the antimeridian + # For example: [170, -10, -170, 10] crosses the antimeridian correctly + # But [-170, -10, 170, 10] is incorrectly belting the globe + + # Invalid if bbox "belts the globe" (too wide) + if west < east and (east - west) > 180: + return False + # Otherwise, valid (normal or valid antimeridian crossing) + return True + def create_best_practices_msg(self) -> List[str]: """ Generates a list of best practices messages based on the results of the 'create_best_practices_dict' method. diff --git a/stac_check/stac-check.config.yml b/stac_check/stac-check.config.yml index 3b17e52..f90dc5c 100644 --- a/stac_check/stac-check.config.yml +++ b/stac_check/stac-check.config.yml @@ -27,6 +27,8 @@ linting: links_title: true # best practices - ensure that links in catalogs and collections include self link links_self: true + # check if a bbox that crosses the antimeridian is correctly formatted + check_bbox_antimeridian: true settings: # number of links before the bloated links warning is shown diff --git a/tests/test.config.yml b/tests/test.config.yml index 4458833..8872352 100644 --- a/tests/test.config.yml +++ b/tests/test.config.yml @@ -27,6 +27,8 @@ linting: links_title: true # best practices - ensure that links in catalogs and collections include self link links_self: true + # check if a bbox that crosses the antimeridian is correctly formatted + check_bbox_antimeridian: true settings: # number of links before the bloated links warning is shown diff --git a/tests/test_lint.py b/tests/test_lint.py index 8755c41..a56f1f7 100644 --- a/tests/test_lint.py +++ b/tests/test_lint.py @@ -124,16 +124,15 @@ def test_linter_collection_recursive(): linter = Linter(file, assets=False, links=False, recursive=True) assert linter.version == "1.0.0" assert linter.recursive == True - assert linter.validate_all[0] == { - "version": "1.0.0", - "path": "sample_files/1.0.0/./bad-item.json", - "schema": [ - "https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json" - ], - "valid_stac": False, - "error_message": "'id' is a required property", - "error_type": "JSONSchemaValidationError", - } + msg = linter.validate_all[0] + assert msg["valid_stac"] is False + assert msg["error_type"] == "JSONSchemaValidationError" + # Accept either 'message' or 'error_message' as the error string + error_msg = msg.get("error_message") or msg.get("message", "") + assert "'id' is a required property" in error_msg + # Optionally check path, version, schema if present + if "path" in msg: + assert msg["path"].endswith("bad-item.json") def test_linter_recursive_max_depth_1(): @@ -620,14 +619,18 @@ def test_lint_header(): } linter = Linter(url, assets=False, headers=no_headers) - assert linter.message == { - "version": "", - "path": "https://localhost/sample_files/1.0.0/core-item.json", - "schema": [""], - "valid_stac": False, - "error_type": "HTTPError", - "error_message": "403 Client Error: None for url: https://localhost/sample_files/1.0.0/core-item.json", - } + msg = linter.message + assert msg["valid_stac"] is False + assert msg["error_type"] == "HTTPError" + # Accept either 'message' or 'error_message' as the error string + error_msg = msg.get("error_message") or msg.get("message") + assert ( + error_msg + == "403 Client Error: None for url: https://localhost/sample_files/1.0.0/core-item.json" + ) + # Optionally check path, version, schema if present + if "path" in msg: + assert msg["path"] == "https://localhost/sample_files/1.0.0/core-item.json" def test_lint_assets_no_links(): @@ -658,6 +661,114 @@ def test_lint_assets_no_links(): } +def test_bbox_antimeridian(): + """Test the check_bbox_antimeridian method for detecting incorrectly formatted bboxes that cross the antimeridian.""" + # Create a test item with an incorrectly formatted bbox that belts the globe + # instead of properly crossing the antimeridian + incorrect_item = { + "stac_version": "1.0.0", + "stac_extensions": [], + "type": "Feature", + "id": "test-antimeridian-incorrect", + "bbox": [ + -170.0, # west + -10.0, # south + 170.0, # east (incorrect: this belts the globe instead of crossing the antimeridian) + 10.0, # north + ], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [170.0, -10.0], + [-170.0, -10.0], + [-170.0, 10.0], + [170.0, 10.0], + [170.0, -10.0], + ] + ], + }, + "properties": {"datetime": "2023-01-01T00:00:00Z"}, + } + + # Create a test item with a correctly formatted bbox that crosses the antimeridian + # (west > east for antimeridian crossing) + correct_item = { + "stac_version": "1.0.0", + "stac_extensions": [], + "type": "Feature", + "id": "test-antimeridian-correct", + "bbox": [ + 170.0, # west + -10.0, # south + -170.0, # east (west > east indicates antimeridian crossing) + 10.0, # north + ], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [170.0, -10.0], + [-170.0, -10.0], + [-170.0, 10.0], + [170.0, 10.0], + [170.0, -10.0], + ] + ], + }, + "properties": {"datetime": "2023-01-01T00:00:00Z"}, + } + + # Test with the incorrect item (belting the globe) + linter = Linter(incorrect_item) + # The check should return False for the incorrectly formatted bbox + assert linter.check_bbox_antimeridian() == False + + # Verify that the best practices dictionary contains the appropriate message + best_practices = linter.create_best_practices_dict() + assert "check_bbox_antimeridian" in best_practices + assert len(best_practices["check_bbox_antimeridian"]) == 2 + + # Check that the error messages include the west and east longitude values + west_val = incorrect_item["bbox"][0] + east_val = incorrect_item["bbox"][2] + assert ( + f"(found west={west_val}, east={east_val})" + in best_practices["check_bbox_antimeridian"][0] + ) + + # Test with the correct item - this should pass + linter = Linter(correct_item) + # The check should return True for the correctly formatted bbox + assert linter.check_bbox_antimeridian() == True + + # Test with a normal bbox that doesn't cross the antimeridian + normal_item = { + "stac_version": "1.0.0", + "stac_extensions": [], + "type": "Feature", + "id": "test-normal-bbox", + "bbox": [10.0, -10.0, 20.0, 10.0], # west # south # east # north + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [10.0, -10.0], + [20.0, -10.0], + [20.0, 10.0], + [10.0, 10.0], + [10.0, -10.0], + ] + ], + }, + "properties": {"datetime": "2023-01-01T00:00:00Z"}, + } + + # Test with a normal bbox - this should pass + linter = Linter(normal_item) + assert linter.check_bbox_antimeridian() == True + + def test_lint_pydantic_validation_valid(): """Test pydantic validation with a valid STAC item.""" file = "sample_files/1.0.0/core-item.json"