diff --git a/CHANGELOG.md b/CHANGELOG.md index bd05fb064..85e232215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - `sort_links_by_id` to Catalog `get_child()` and `modify_links` to `get_stac_objects()` ([#1064](https://github.com/stac-utils/pystac/pull/1064)) - `*ids` to Catalog and Collection `get_items()` for only including the provided ids in the iterator ([#1075](https://github.com/stac-utils/pystac/pull/1075)) - `recursive` to Catalog and Collection `get_items()` to walk the sub-catalogs and sub-collections ([#1075](https://github.com/stac-utils/pystac/pull/1075)) +- MGRS Extension ([#1088](https://github.com/stac-utils/pystac/pull/1088)) ### Changed diff --git a/docs/api.rst b/docs/api.rst index c1e1a84dc..604000928 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -96,6 +96,7 @@ PySTAC provides support for the following STAC Extensions: * :mod:`File Info ` * :mod:`Item Assets ` * :mod:`Label ` +* :mod:`MGRS ` * :mod:`Point Cloud ` * :mod:`Projection ` * :mod:`Raster ` diff --git a/docs/api/extensions/mgrs.rst b/docs/api/extensions/mgrs.rst new file mode 100644 index 000000000..880f8525d --- /dev/null +++ b/docs/api/extensions/mgrs.rst @@ -0,0 +1,7 @@ +pystac.extensions.mgrs +============================ + +.. automodule:: pystac.extensions.mgrs + :members: + :undoc-members: + :show-inheritance: diff --git a/pystac/__init__.py b/pystac/__init__.py index 68350f909..c3da1a9ee 100644 --- a/pystac/__init__.py +++ b/pystac/__init__.py @@ -89,6 +89,7 @@ import pystac.extensions.file import pystac.extensions.item_assets import pystac.extensions.label +import pystac.extensions.mgrs import pystac.extensions.pointcloud import pystac.extensions.projection import pystac.extensions.sar @@ -107,6 +108,7 @@ pystac.extensions.file.FILE_EXTENSION_HOOKS, pystac.extensions.item_assets.ITEM_ASSETS_EXTENSION_HOOKS, pystac.extensions.label.LABEL_EXTENSION_HOOKS, + pystac.extensions.mgrs.MGRS_EXTENSION_HOOKS, pystac.extensions.pointcloud.POINTCLOUD_EXTENSION_HOOKS, pystac.extensions.projection.PROJECTION_EXTENSION_HOOKS, pystac.extensions.sar.SAR_EXTENSION_HOOKS, diff --git a/pystac/extensions/mgrs.py b/pystac/extensions/mgrs.py new file mode 100644 index 000000000..5e3cc7beb --- /dev/null +++ b/pystac/extensions/mgrs.py @@ -0,0 +1,249 @@ +"""Implements the :stac-ext:`MGRS Extension `.""" + +import re +from typing import Any, Dict, FrozenSet, Optional, Pattern, Set, Union + +import pystac +from pystac.extensions.base import ExtensionManagementMixin, PropertiesExtension +from pystac.extensions.hooks import ExtensionHooks + +SCHEMA_URI: str = "https://stac-extensions.github.io/mgrs/v1.0.0/schema.json" +PREFIX: str = "mgrs:" + +# Field names +LATITUDE_BAND_PROP: str = PREFIX + "latitude_band" # required +GRID_SQUARE_PROP: str = PREFIX + "grid_square" # required +UTM_ZONE_PROP: str = PREFIX + "utm_zone" + +LATITUDE_BANDS: FrozenSet[str] = frozenset( + { + "C", + "D", + "E", + "F", + "G", + "H", + "J", + "K", + "L", + "M", + "N", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + } +) + +UTM_ZONES: FrozenSet[int] = frozenset( + { + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + } +) + +GRID_SQUARE_REGEX: str = ( + r"[ABCDEFGHJKLMNPQRSTUVWXYZ][ABCDEFGHJKLMNPQRSTUV](\d{2}|\d{4}|\d{6}|\d{8}|\d{10})?" +) +GRID_SQUARE_PATTERN: Pattern[str] = re.compile(GRID_SQUARE_REGEX) + + +def validated_latitude_band(v: str) -> str: + if not isinstance(v, str): + raise ValueError("Invalid MGRS latitude band: must be str") + if v not in LATITUDE_BANDS: + raise ValueError(f"Invalid MGRS latitude band: {v} is not in {LATITUDE_BANDS}") + return v + + +def validated_grid_square(v: str) -> str: + if not isinstance(v, str): + raise ValueError("Invalid MGRS grid square identifier: must be str") + if not GRID_SQUARE_PATTERN.fullmatch(v): + raise ValueError( + f"Invalid MGRS grid square identifier: {v}" + f" does not match the regex {GRID_SQUARE_REGEX}" + ) + return v + + +def validated_utm_zone(v: Optional[int]) -> Optional[int]: + if v is not None and not isinstance(v, int): + raise ValueError("Invalid MGRS utm zone: must be None or int") + if v is not None and v not in UTM_ZONES: + raise ValueError(f"Invalid MGRS UTM zone: {v} is not in {UTM_ZONES}") + return v + + +class MgrsExtension( + PropertiesExtension, + ExtensionManagementMixin[Union[pystac.Item, pystac.Collection]], +): + """A concrete implementation of :class:`MgrsExtension` on an :class:`~pystac.Item` + that extends the properties of the Item to include properties defined in the + :stac-ext:`MGRS Extension `. + + This class should generally not be instantiated directly. Instead, call + :meth:`MgrsExtension.ext` on an :class:`~pystac.Item` to extend it. + + .. code-block:: python + + >>> item: pystac.Item = ... + >>> proj_ext = MgrsExtension.ext(item) + """ + + item: pystac.Item + """The :class:`~pystac.Item` being extended.""" + + properties: Dict[str, Any] + """The :class:`~pystac.Item` properties, including extension properties.""" + + def __init__(self, item: pystac.Item): + self.item = item + self.properties = item.properties + + def __repr__(self) -> str: + return "".format(self.item.id) + + def apply( + self, + latitude_band: str, + grid_square: str, + utm_zone: Optional[int] = None, + ) -> None: + """Applies MGRS extension properties to the extended Item. + + Args: + latitude_band : REQUIRED. The latitude band of the Item's centroid. + grid_square : REQUIRED. MGRS grid square of the Item's centroid. + utm_zone : The UTM Zone of the Item centroid. + """ + self.latitude_band = validated_latitude_band(latitude_band) + self.grid_square = validated_grid_square(grid_square) + self.utm_zone = validated_utm_zone(utm_zone) + + @property + def latitude_band(self) -> Optional[str]: + """Get or sets the latitude band of the datasource.""" + return self._get_property(LATITUDE_BAND_PROP, str) + + @latitude_band.setter + def latitude_band(self, v: str) -> None: + self._set_property( + LATITUDE_BAND_PROP, validated_latitude_band(v), pop_if_none=False + ) + + @property + def grid_square(self) -> Optional[str]: + """Get or sets the latitude band of the datasource.""" + return self._get_property(GRID_SQUARE_PROP, str) + + @grid_square.setter + def grid_square(self, v: str) -> None: + self._set_property( + GRID_SQUARE_PROP, validated_grid_square(v), pop_if_none=False + ) + + @property + def utm_zone(self) -> Optional[int]: + """Get or sets the latitude band of the datasource.""" + return self._get_property(UTM_ZONE_PROP, int) + + @utm_zone.setter + def utm_zone(self, v: Optional[int]) -> None: + self._set_property(UTM_ZONE_PROP, validated_utm_zone(v), pop_if_none=True) + + @classmethod + def get_schema_uri(cls) -> str: + return SCHEMA_URI + + @classmethod + def ext(cls, obj: pystac.Item, add_if_missing: bool = False) -> "MgrsExtension": + """Extends the given STAC Object with properties from the :stac-ext:`MGRS + Extension `. + + This extension can be applied to instances of :class:`~pystac.Item`. + + Raises: + + pystac.ExtensionTypeError : If an invalid object type is passed. + """ + if isinstance(obj, pystac.Item): + cls.validate_has_extension(obj, add_if_missing) + return MgrsExtension(obj) + else: + raise pystac.ExtensionTypeError( + f"MGRS Extension does not apply to type '{type(obj).__name__}'" + ) + + +class MgrsExtensionHooks(ExtensionHooks): + schema_uri: str = SCHEMA_URI + prev_extension_ids: Set[str] = set() + stac_object_types = {pystac.STACObjectType.ITEM} + + +MGRS_EXTENSION_HOOKS: ExtensionHooks = MgrsExtensionHooks() diff --git a/tests/conftest.py b/tests/conftest.py index d449add98..95531df2a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ # TODO move all test case code to this file +from pathlib import Path from datetime import datetime import pytest @@ -9,6 +10,9 @@ from .utils import ARBITRARY_BBOX, ARBITRARY_EXTENT, ARBITRARY_GEOM, TestCases +here = Path(__file__).resolve().parent + + @pytest.fixture def catalog() -> Catalog: return Catalog("test-catalog", "A test catalog") @@ -38,3 +42,7 @@ def test_case_8_collection() -> Collection: def projection_landsat8_item() -> Item: path = TestCases.get_path("data-files/projection/example-landsat8.json") return Item.from_file(path) + + +def get_data_file(rel_path: str) -> str: + return str(here / "data-files" / rel_path) diff --git a/tests/data-files/mgrs/item.json b/tests/data-files/mgrs/item.json new file mode 100644 index 000000000..97a56b19b --- /dev/null +++ b/tests/data-files/mgrs/item.json @@ -0,0 +1,53 @@ +{ + "stac_version": "1.0.0", + "stac_extensions": [ + "https://stac-extensions.github.io/mgrs/v1.0.0/schema.json" + ], + "type": "Feature", + "id": "item", + "bbox": [ + 172.9, + 1.3, + 173, + 1.4 + ], + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + 172.9, + 1.3 + ], + [ + 173, + 1.3 + ], + [ + 173, + 1.4 + ], + [ + 172.9, + 1.4 + ], + [ + 172.9, + 1.3 + ] + ] + ] + }, + "properties": { + "datetime": "2020-12-11T22:38:32Z", + "mgrs:utm_zone": 13, + "mgrs:latitude_band": "X", + "mgrs:grid_square": "DH" + }, + "links": [], + "assets": { + "data": { + "href": "https://example.com/examples/file.xyz" + } + } +} \ No newline at end of file diff --git a/tests/extensions/test_mgrs.py b/tests/extensions/test_mgrs.py new file mode 100644 index 000000000..5e8b03c99 --- /dev/null +++ b/tests/extensions/test_mgrs.py @@ -0,0 +1,126 @@ +"""Tests for pystac.tests.extensions.mgrs""" +import json +import pytest +import pystac +from pystac.extensions.mgrs import MgrsExtension + + +from tests.conftest import get_data_file + + +@pytest.fixture +def ext_item_uri() -> str: + return get_data_file("mgrs/item.json") + + +@pytest.fixture +def ext_item(ext_item_uri: str) -> pystac.Item: + return pystac.Item.from_file(ext_item_uri) + + +def test_stac_extensions(ext_item: pystac.Item) -> None: + assert MgrsExtension.has_extension(ext_item) + + +def test_get_schema_uri(ext_item: pystac.Item) -> None: + assert MgrsExtension.get_schema_uri() in ext_item.stac_extensions + + +def test_ext_raises_if_item_does_not_conform(item: pystac.Item) -> None: + with pytest.raises(pystac.errors.ExtensionNotImplemented): + MgrsExtension.ext(item) + + +def test_to_from_dict(ext_item_uri: str, ext_item: pystac.Item) -> None: + with open(ext_item_uri) as f: + d = json.load(f) + actual = ext_item.to_dict(include_self_link=False) + assert actual == d + + +def test_add_to(item: pystac.Item) -> None: + assert not MgrsExtension.has_extension(item) + MgrsExtension.add_to(item) + + assert MgrsExtension.has_extension(item) + + +def test_apply(item: pystac.Item) -> None: + MgrsExtension.add_to(item) + MgrsExtension.ext(item).apply(latitude_band="X", grid_square="DH") + assert MgrsExtension.ext(item).latitude_band + assert MgrsExtension.ext(item).grid_square + + +def test_apply_without_required_fields_raises(item: pystac.Item) -> None: + MgrsExtension.add_to(item) + with pytest.raises(TypeError, match="missing 2 required positional arguments"): + MgrsExtension.ext(item).apply() # type: ignore + + +def test_validate(ext_item: pystac.Item) -> None: + assert ext_item.validate() + + +@pytest.mark.parametrize("field", ["latitude_band", "grid_square", "utm_zone"]) +def test_get_field(ext_item: pystac.Item, field: str) -> None: + prop = ext_item.properties[f"mgrs:{field}"] + attr = getattr(MgrsExtension.ext(ext_item), field) + + assert attr is not None + assert attr == prop + + +@pytest.mark.parametrize( + "field,value", + [ + ("latitude_band", "C"), + ("grid_square", "ZA"), + ("utm_zone", 59), + ], +) +def test_set_field(ext_item: pystac.Item, field: str, value) -> None: # type: ignore + original = ext_item.properties[f"mgrs:{field}"] + setattr(MgrsExtension.ext(ext_item), field, value) + new = ext_item.properties[f"mgrs:{field}"] + + assert new != original + assert new == value + assert ext_item.validate() + + +def test_utm_zone_set_to_none_pops_from_dict(ext_item: pystac.Item) -> None: + assert "mgrs:utm_zone" in ext_item.properties + + MgrsExtension.ext(ext_item).utm_zone = None + assert "mgrs:utm_zone" not in ext_item.properties + + +def test_invalid_latitude_band_raises_informative_error(ext_item: pystac.Item) -> None: + with pytest.raises(ValueError, match="must be str"): + MgrsExtension.ext(ext_item).latitude_band = 2 # type: ignore + + with pytest.raises(ValueError, match="must be str"): + MgrsExtension.ext(ext_item).latitude_band = None + + with pytest.raises(ValueError, match="a is not in "): + MgrsExtension.ext(ext_item).latitude_band = "a" + + +def test_invalid_grid_square_raises_informative_error(ext_item: pystac.Item) -> None: + with pytest.raises(ValueError, match="must be str"): + MgrsExtension.ext(ext_item).grid_square = 2 # type: ignore + + with pytest.raises(ValueError, match="must be str"): + MgrsExtension.ext(ext_item).grid_square = None + + with pytest.raises(ValueError, match="nv does not match the regex "): + MgrsExtension.ext(ext_item).grid_square = "nv" + + +def test_invalid_utm_zone_raises_informative_error(ext_item: pystac.Item) -> None: + with pytest.raises(ValueError, match="must be None or int"): + MgrsExtension.ext(ext_item).utm_zone = "foo" # type: ignore + + with pytest.raises(ValueError, match="61 is not in "): + MgrsExtension.ext(ext_item).utm_zone = 61