diff --git a/src/gfwapiclient/base/models.py b/src/gfwapiclient/base/models.py index edb3dda..5370e94 100644 --- a/src/gfwapiclient/base/models.py +++ b/src/gfwapiclient/base/models.py @@ -1,14 +1,40 @@ """Global Fishing Watch (GFW) API Python Client - Base Models.""" from enum import Enum -from typing import Any, ClassVar, Optional - -from pydantic import AliasGenerator, ConfigDict, Field, field_validator +from pathlib import Path +from typing import ( + Any, + ClassVar, + Dict, + List, + Optional, + Protocol, + Self, + Union, + cast, + runtime_checkable, +) + +import geopandas as gpd + +from geojson_pydantic.features import Feature, FeatureCollection +from geojson_pydantic.geometries import Geometry, GeometryCollection +from pydantic import ( + AliasGenerator, + ConfigDict, + Field, + FilePath, + Json, + TypeAdapter, + ValidationError, + field_validator, + model_validator, +) from pydantic import BaseModel as PydanticBaseModel from pydantic.alias_generators import to_camel -__all__ = ["BaseModel", "Region", "RegionDataset"] +__all__ = ["BaseModel", "GeoJson", "OnInvalid", "Region", "RegionDataset"] class BaseModel(PydanticBaseModel): @@ -84,7 +110,8 @@ class Region(BaseModel): - Marine Protected Areas (MPA) - Regional Fisheries Management Organizations (RFMO) - The predefined region (or area) of interest are used in other API endpoints when: + The predefined region (or area) of interest are used in other + Global Fishing Watch API endpoints when: - Create a report of a specified region. See: https://globalfishingwatch.org/our-apis/documentation#create-a-report-of-a-specified-region @@ -156,3 +183,322 @@ def normalize_id(cls, value: Any) -> Optional[Any]: return None return value + + +class OnInvalid(str, Enum): + """Error handling strategy. + + Attributes: + RAISE (str): + Raise the underlying exception immediately. + + IGNORE (str): + Suppress the underlying exception and return `None`. + """ + + RAISE = "raise" + IGNORE = "ignore" + + +@runtime_checkable +class SupportsGeoJsonInterface(Protocol): + """Protocol for objects exposing a GeoJSON interface. + + For more details on `GeoJSON` and `__geo_interface__`, please refer + to the official documentations: + + See: https://geojson.org/ + + See: https://gist.github.com/sgillies/2217756 + """ + + @property + def __geo_interface__(self) -> Dict[str, Any]: ... # pragma: no cover + + +class GeoJson(FeatureCollection[Feature[Geometry, Union[Dict[str, Any], BaseModel]]]): + """Custom GeoJSON-compatible region (or area) of interest. + + Represents a GeoJSON-compatible custom geographic region (or area) of interest + supported by the Global Fishing Watch APIs. + + The GeoJSON-compatible custom geographic regions (areas of interest) are used + in other Global Fishing Watch API endpoints when: + + - Create a report of a specified region. + See: https://globalfishingwatch.org/our-apis/documentation#create-a-report-of-a-specified-region + + - Get All Events: + See: https://globalfishingwatch.org/our-apis/documentation#get-all-events-post-endpoint + + - Create a Bulk Report. + See https://globalfishingwatch.org/our-apis/documentation#create-a-bulk-report + + For more details on `GeoJSON` and `__geo_interface__`, please refer + to the official documentations: + + See: https://geojson.org/ + + See: https://gist.github.com/sgillies/2217756 + + Attributes: + type (Literal["FeatureCollection"]): + The GeoJSON object type. Always set to `"FeatureCollection"`. + + features (List[Feature[Geometry, Union[Dict[str, Any], BaseModel]]]): + A list of GeoJSON Feature objects contained in this collection. + Each feature consists of a geometry object and an optional + properties object. + """ + + _file_path_adapter: ClassVar[Optional[TypeAdapter[FilePath]]] = None + _json_dict_adapter: ClassVar[Optional[TypeAdapter[Json[Dict[str, Any]]]]] = None + + def to_geometry(self) -> Geometry: + """Convert a `GeoJson` object into into a single or collection of geometry. + + This method extracts and aggregates all geometries contained within the + features of `GeoJson` object. The resulting `Geometry` represents the + union of all geometries contained in the `GeoJson`: + + - Multiple features are converted into a `GeometryCollection`. + - Individual features return their geometry directly i.e., `Point`, + `MultiPoint`, `LineString`, `MultiLineString`, `Polygon`, or `MultiPolygon`. + - Features without geometries (`geometry=None`) are ignored. + + Returns: + Geometry: + A single or collection of geometry representing all geometries contained in the `GeoJson` object. + + Raises: + ValueError: + If the `GeoJson` object contains no valid geometries. + """ + geometries: List[Geometry] = [] + for feature in self.features or []: + if feature.geometry: + if isinstance(feature.geometry, GeometryCollection): + for geometry in feature.geometry.geometries or []: + geometries.append(geometry) + else: + geometries.append(feature.geometry) + + if not geometries: + raise ValueError( + f"Expected `GeoJson` object with valid geometries but received {self!r}" + ) + + if len(geometries) == 1: + return geometries[0] + + return GeometryCollection.create( + geometries=geometries, + ) + + @model_validator(mode="before") + @classmethod + def normalize_geojson(cls, value: Any) -> Optional[Any]: + """Normalize arbitrary input into a GeoJSON FeatureCollection. + + Converts different forms of GeoJSON-compatible input into a standard + FeatureCollection format, which is the internal representation used by + `GeoJson`. + + Args: + value (Any): + The value to normalize. + + Returns: + Optional[Any]: + The normalized GeoJSON FeatureCollection, otherwise the input + is returned as-is. + """ + # Normalize GeoJSON-compatible dict inputs + if isinstance(value, dict) and "type" in value: + geojson_type: Optional[str] = value.get("type") + + if geojson_type == "FeatureCollection": + return value + + # Wrap a GeoJSON Feature into a FeatureCollection + if geojson_type == "Feature": + return { + "type": "FeatureCollection", + "features": [value], + } + + # Wrap a GeoJSON Geometry/GeometryCollection into a FeatureCollection + if geojson_type and ("coordinates" in value or "geometries" in value): + return { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": None, + "geometry": value, + } + ], + } + + return value + + @classmethod + def from_file_or_geojson( + cls, + *, + source: Union[str, Path, Dict[str, Any], SupportsGeoJsonInterface], + **kwargs: Dict[str, Any], + ) -> Self: + """Create a `GeoJson` instance from a spatial data source. + + Reads a spatial file (e.g., GeoJSON, Shapefile, etc.), GeoJSON-string, + GeoJSON-dictionary, GeoDataFrame, or an object implementing `__geo_interface__` + and converts it into a `GeoJson` instance. + + For more details on `GeoJSON` and `__geo_interface__`, please refer + to the official documentations: + + See: https://geojson.org/ + + See: https://gist.github.com/sgillies/2217756 + + When `source` is a filesystem path, the file is read using `geopandas.read_file`. + + Supported formats depend on the underlying GDAL installation, + commonly including: + + - GeoJSON (.geojson, .json) + - ESRI Shapefile (.shp) + - GeoPackage (.gpkg) + - FlatGeobuf (.fgb) + - KML / KMZ (if enabled) + - many additional OGR-supported formats + + See: https://geopandas.org/en/stable/docs/reference/api/geopandas.read_file.html + + See: https://gdal.org/drivers/vector/index.html + + A properly configured GDAL installation is required. + + Args: + source (Union[str, Path, Dict[str, Any], SupportsGeoJsonInterface]): + Spatial input source. + + Path to a spatial file (e.g., GeoJSON, Shapefile, etc.). + Example: `"path/to/your/spatial/file.shp"` or + `"path/to/your/spatial/file.json"`. + + GeoJSON-compatible object provided as a JSON-string, Python dictionary, + `geopandas.GeoDataFrame`, `shapely`, or an object implementing + `__geo_interface__`. + Example: `'{"type": "Polygon", "coordinates": [...]}'` or + `{"type": "Polygon", "coordinates": [...]}`. + + **kwargs (Dict[str, Any]): + Additional keyword arguments passed to `geopandas.read_file()` + when reading from a file. + + Returns: + GeoJson: + A fully populated `GeoJson` instance. + + Raises: + ValueError: + If the source cannot be interpreted as a valid spatial input source. + + ValidationError: + If `GeoJson` validation fails. + """ + # Normalize `str` source to `Path` or `Dict` + _source: Optional[Union[Path, Dict[str, Any], SupportsGeoJsonInterface]] = ( + cls.parse_file_path(value=source) or cls.parse_geojson_json(value=source) + if isinstance(source, str) + else source + ) + if not isinstance(_source, (Path, dict, SupportsGeoJsonInterface)): + raise ValueError( + f"Expected a non-empty GeoJSON-compatible source `(Path, dict, SupportsGeoJsonInterface)` but received {type(source)}: {source!r}" + ) + + # Create from a spatial file e.g., GeoJSON file, Shapefile etc. + _geojson: Union[gpd.GeoDataFrame, Dict[str, Any] | SupportsGeoJsonInterface] = ( + cast(gpd.GeoDataFrame, gpd.read_file(_source, **kwargs)) + if isinstance(_source, Path) + else _source + ) + + # Create from an object implementing `__geo_interface__` + # e.g., GeoDataFrame, shapely etc. + if isinstance(_geojson, SupportsGeoJsonInterface): + _geojson = _geojson.__geo_interface__ + + return cls(**_geojson) + + @classmethod + def parse_file_path( + cls, *, value: str, on_invalid: OnInvalid = OnInvalid.IGNORE + ) -> Optional[Path]: + """Parse, validate and convert a filesystem path into `Path`. + + Attempts to interpret a string as a valid existing file path using + Pydantic's `TypeAdapter` and `FilePath` validator. + + Args: + value (str): + Candidate filesystem path. + + on_invalid (OnInvalid, default=OnInvalid.IGNORE): + Behaviour when parsing or validation fails. + + Returns: + Optional[Path]: + Validated path if successful, otherwise `None`. + + Raises: + ValidationError: + If validation fails and `on_invalid=OnInvalid.RAISE`. + + OSError: + If filesystem access fails and `on_invalid=OnInvalid.RAISE`. + """ + try: + if not cls._file_path_adapter: + cls._file_path_adapter = TypeAdapter(FilePath) + + return cls._file_path_adapter.validate_python(value) + except (ValidationError, OSError) as exc: + if on_invalid == "raise": + raise exc + return None + + @classmethod + def parse_geojson_json( + cls, *, value: str, on_invalid: OnInvalid = OnInvalid.IGNORE + ) -> Optional[Dict[str, Any]]: + """Parse, validate and convert a GeoJSON JSON-string into `dict`. + + Args: + value (str): + JSON string expected to represent a GeoJSON object. + + on_invalid (OnInvalid, default=OnInvalid.IGNORE): + Behaviour when parsing or validation fails. + + Returns: + Optional[Dict[str, Any]]: + Parsed GeoJSON dictionary or `None`. + + Raises: + ValidationError: + If JSON parsing or validation fails and `on_invalid=OnInvalid.RAISE`. + + """ + try: + if not cls._json_dict_adapter: + cls._json_dict_adapter = TypeAdapter(Json[Dict[str, Any]]) + + return cls._json_dict_adapter.validate_python(value) + except ValidationError as exc: + if on_invalid == "raise": + raise exc + return None diff --git a/src/gfwapiclient/http/models/request.py b/src/gfwapiclient/http/models/request.py index 35d6476..5c95468 100644 --- a/src/gfwapiclient/http/models/request.py +++ b/src/gfwapiclient/http/models/request.py @@ -96,6 +96,8 @@ class RequestBody(BaseModel): ensuring proper handling of null values and field aliases. """ + # geojson_fields: ClassVar[Optional[List[str]]] = None model_dump(mode="json") + def to_json_body(self, **kwargs: Any) -> Dict[str, Any]: """Converts the `RequestBody` instance to a JSON-compatible HTTP request body. diff --git a/tests/base/conftest.py b/tests/base/conftest.py new file mode 100644 index 0000000..d92388d --- /dev/null +++ b/tests/base/conftest.py @@ -0,0 +1,240 @@ +"""Test configurations for `gfwapiclient.base`.""" + +from typing import Any, Callable, Dict, List + +import pytest + + +@pytest.fixture +def mock_raw_geojson_feature( + load_json_fixture: Callable[[str], Dict[str, Any]], +) -> Dict[str, Any]: + """Fixture for a mock raw geojson feature. + + This fixture loads sample JSON data representing a + `GeoJson` feature from a fixture file. + + Returns: + Dict[str, Any]: + Raw `GeoJson` sample data as a dictionary. + """ + raw_geojson_feature: Dict[str, Any] = load_json_fixture( + "base/geojson/geojson_feature.json" + ) + return raw_geojson_feature + + +@pytest.fixture +def mock_raw_geojson_feature_collection( + load_json_fixture: Callable[[str], Dict[str, Any]], +) -> Dict[str, Any]: + """Fixture for a mock raw geojson feature collection. + + This fixture loads sample JSON data representing a + `GeoJson` feature collection from a fixture file. + + Returns: + Dict[str, Any]: + Raw `GeoJson` sample data as a dictionary. + """ + raw_geojson_feature_collection: Dict[str, Any] = load_json_fixture( + "base/geojson/geojson_featurecollection.json" + ) + return raw_geojson_feature_collection + + +@pytest.fixture +def mock_raw_geojson_geometrycollection( + load_json_fixture: Callable[[str], Dict[str, Any]], +) -> Dict[str, Any]: + """Fixture for a mock raw geojson geometry collection. + + This fixture loads sample JSON data representing a + `GeoJson` geojson geometry collection from a fixture file. + + Returns: + Dict[str, Any]: + Raw `GeoJson` sample data as a dictionary. + """ + raw_geojson_geometrycollection: Dict[str, Any] = load_json_fixture( + "base/geojson/geojson_geometrycollection.json" + ) + return raw_geojson_geometrycollection + + +@pytest.fixture +def mock_raw_geojson_linestring( + load_json_fixture: Callable[[str], Dict[str, Any]], +) -> Dict[str, Any]: + """Fixture for a mock raw geojson linestring. + + This fixture loads sample JSON data representing a + `GeoJson` linestring from a fixture file. + + Returns: + Dict[str, Any]: + Raw `GeoJson` sample data as a dictionary. + """ + raw_geojson_linestring: Dict[str, Any] = load_json_fixture( + "base/geojson/geojson_linestring.json" + ) + return raw_geojson_linestring + + +@pytest.fixture +def mock_raw_geojson_multilinestring( + load_json_fixture: Callable[[str], Dict[str, Any]], +) -> Dict[str, Any]: + """Fixture for a mock raw geojson multilinestring. + + This fixture loads sample JSON data representing a + `GeoJson` multilinestring from a fixture file. + + Returns: + Dict[str, Any]: + Raw `GeoJson` sample data as a dictionary. + """ + raw_geojson_multilinestring: Dict[str, Any] = load_json_fixture( + "base/geojson/geojson_multilinestring.json" + ) + return raw_geojson_multilinestring + + +@pytest.fixture +def mock_raw_geojson_multipoint( + load_json_fixture: Callable[[str], Dict[str, Any]], +) -> Dict[str, Any]: + """Fixture for a mock raw geojson multipoint. + + This fixture loads sample JSON data representing a + `GeoJson` multipoint from a fixture file. + + Returns: + Dict[str, Any]: + Raw `GeoJson` sample data as a dictionary. + """ + raw_geojson_multipoint: Dict[str, Any] = load_json_fixture( + "base/geojson/geojson_multipoint.json" + ) + return raw_geojson_multipoint + + +@pytest.fixture +def mock_raw_geojson_multipolygon( + load_json_fixture: Callable[[str], Dict[str, Any]], +) -> Dict[str, Any]: + """Fixture for a mock raw geojson multipolygon. + + This fixture loads sample JSON data representing a + `GeoJson` multipolygon from a fixture file. + + Returns: + Dict[str, Any]: + Raw `GeoJson` sample data as a dictionary. + """ + raw_geojson_multipolygon: Dict[str, Any] = load_json_fixture( + "base/geojson/geojson_multipolygon.json" + ) + return raw_geojson_multipolygon + + +@pytest.fixture +def mock_raw_geojson_point( + load_json_fixture: Callable[[str], Dict[str, Any]], +) -> Dict[str, Any]: + """Fixture for a mock raw geojson point. + + This fixture loads sample JSON data representing a + `GeoJson` point from a fixture file. + + Returns: + Dict[str, Any]: + Raw `GeoJson` sample data as a dictionary. + """ + raw_geojson_point: Dict[str, Any] = load_json_fixture( + "base/geojson/geojson_point.json" + ) + return raw_geojson_point + + +@pytest.fixture +def mock_raw_geojson_polygon( + load_json_fixture: Callable[[str], Dict[str, Any]], +) -> Dict[str, Any]: + """Fixture for a mock raw geojson polygon. + + This fixture loads sample JSON data representing a + `GeoJson` polygon from a fixture file. + + Returns: + Dict[str, Any]: + Raw `GeoJson` sample data as a dictionary. + """ + raw_geojson_polygon: Dict[str, Any] = load_json_fixture( + "base/geojson/geojson_polygon.json" + ) + return raw_geojson_polygon + + +@pytest.fixture +def mock_raw_geojson_features( + mock_raw_geojson_geometrycollection: Dict[str, Any], + mock_raw_geojson_linestring: Dict[str, Any], + mock_raw_geojson_multilinestring: Dict[str, Any], + mock_raw_geojson_multipoint: Dict[str, Any], + mock_raw_geojson_multipolygon: Dict[str, Any], + mock_raw_geojson_point: Dict[str, Any], + mock_raw_geojson_polygon: Dict[str, Any], +) -> List[Dict[str, Any]]: + """Fixture for a mock raw geojson features. + + This fixture create sample JSON data representing a + list of `GeoJSON` features. + + Returns: + List[Dict[str, Any]]: + Raw `GeoJSON` sample features data as a list of dictionaries. + """ + raw_geojson_features: List[Dict[str, Any]] = [ + { + "type": "Feature", + "geometry": {**mock_raw_geojson_feature}, + "properties": None, + } + for mock_raw_geojson_feature in [ + mock_raw_geojson_geometrycollection, # GeometryCollection + mock_raw_geojson_linestring, # LineString + mock_raw_geojson_multilinestring, # MultiLineString + mock_raw_geojson_multipoint, # MultiPoint + mock_raw_geojson_multipolygon, # MultiPolygon + mock_raw_geojson_point, # Point + mock_raw_geojson_polygon, # Polygon + ] + ] + return raw_geojson_features + + +@pytest.fixture +def mock_raw_geojson_feature_collections( + mock_raw_geojson_features: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Fixture for a mock raw geojson feature collections. + + This fixture create sample JSON data representing a + list of `GeoJSON` feature collections. + + Returns: + List[Dict[str, Any]]: + Raw `GeoJSON` sample feature collections data as a list of dictionaries. + """ + raw_geojson_feature_collections: List[Dict[str, Any]] = [ + { + "type": "FeatureCollection", + "features": mock_raw_features, # At least 2 features + } + for mock_raw_features in [ + mock_raw_geojson_features[-idx:] + for idx in range(2, len(mock_raw_geojson_features) + 1) + ] + ] + return raw_geojson_feature_collections diff --git a/tests/base/test_geojson_models.py b/tests/base/test_geojson_models.py new file mode 100644 index 0000000..09c88d4 --- /dev/null +++ b/tests/base/test_geojson_models.py @@ -0,0 +1,488 @@ +"""Tests for `gfwapiclient.base.models.BaseModel`.""" + +import json + +from pathlib import Path +from typing import Any, Dict, List, Union + +import geopandas as gpd +import pytest +import shapely + +from geojson_pydantic.features import Feature, FeatureCollection +from geojson_pydantic.geometries import ( + Geometry, + GeometryCollection, + LineString, + MultiLineString, + MultiPoint, + MultiPolygon, + Point, + Polygon, +) +from pydantic import ValidationError + +from gfwapiclient.base.models import GeoJson, OnInvalid, SupportsGeoJsonInterface + + +def assert_valid_geojson(geojson: GeoJson) -> None: + """Assert that an object is a valid `GeoJson`. + + Its checks: + - Object is a `GeoJson` instance + - Object is a GeoJSON `FeatureCollection`` + - Supports `__geo_interface__` + - Contains at least one Feature + + Args: + geojson (GeoJson): + Object to validate. + """ + assert isinstance(geojson, GeoJson) + assert isinstance(geojson, FeatureCollection) + assert isinstance(geojson, SupportsGeoJsonInterface) + assert hasattr(geojson, "__geo_interface__") + assert geojson.type == "FeatureCollection" + assert geojson.features is not None + assert geojson.length >= 1 + assert len(geojson.features) >= 1 + assert isinstance(geojson.features[0], Feature) is True + assert hasattr(geojson.features[0], "__geo_interface__") + + +def test_geojson_model_serializes_feature_to_feature_collection( + mock_raw_geojson_feature: Dict[str, Any], +) -> None: + """Test that `GeoJson` serializes feature to feature collection correctly.""" + geojson: GeoJson = GeoJson(**mock_raw_geojson_feature) + + assert_valid_geojson(geojson) + assert geojson.features[0].model_dump(mode="json") == mock_raw_geojson_feature + + +def test_geojson_model_serializes_feature_collection( + mock_raw_geojson_feature_collection: Dict[str, Any], +) -> None: + """Test that `GeoJson` serializes feature collection correctly.""" + geojson: GeoJson = GeoJson(**mock_raw_geojson_feature_collection) + + assert_valid_geojson(geojson) + assert geojson.model_dump(mode="json") == mock_raw_geojson_feature_collection + + +def test_geojson_model_serializes_geometrycollection_to_feature_collection( + mock_raw_geojson_geometrycollection: Dict[str, Any], +) -> None: + """Test that `GeoJson` serializes geometrycollection to feature collection correctly.""" + geojson: GeoJson = GeoJson(**mock_raw_geojson_geometrycollection) + + assert_valid_geojson(geojson) + + feature_model_dump: Dict[str, Any] = geojson.features[0].model_dump(mode="json") + assert ( + feature_model_dump["geometry"]["type"] + == mock_raw_geojson_geometrycollection["type"] + ) + assert ( + feature_model_dump["geometry"]["geometries"] + == mock_raw_geojson_geometrycollection["geometries"] + ) + + +def test_geojson_model_serializes_linestring_to_feature_collection( + mock_raw_geojson_linestring: Dict[str, Any], +) -> None: + """Test that `GeoJson` serializes linestring to feature collection correctly.""" + geojson: GeoJson = GeoJson(**mock_raw_geojson_linestring) + + assert_valid_geojson(geojson) + + feature_model_dump: Dict[str, Any] = geojson.features[0].model_dump(mode="json") + assert feature_model_dump["geometry"]["type"] == mock_raw_geojson_linestring["type"] + assert ( + feature_model_dump["geometry"]["coordinates"] + == mock_raw_geojson_linestring["coordinates"] + ) + + +def test_geojson_model_serializes_multilinestring_to_feature_collection( + mock_raw_geojson_multilinestring: Dict[str, Any], +) -> None: + """Test that `GeoJson` serializes multilinestring to feature collection correctly.""" + geojson: GeoJson = GeoJson(**mock_raw_geojson_multilinestring) + + assert_valid_geojson(geojson) + + feature_model_dump: Dict[str, Any] = geojson.features[0].model_dump(mode="json") + assert ( + feature_model_dump["geometry"]["type"] + == mock_raw_geojson_multilinestring["type"] + ) + assert ( + feature_model_dump["geometry"]["coordinates"] + == mock_raw_geojson_multilinestring["coordinates"] + ) + + +def test_geojson_model_serializes_multipoint_to_feature_collection( + mock_raw_geojson_multipoint: Dict[str, Any], +) -> None: + """Test that `GeoJson` serializes multipoint to feature collection correctly.""" + geojson: GeoJson = GeoJson(**mock_raw_geojson_multipoint) + + assert_valid_geojson(geojson) + + feature_model_dump: Dict[str, Any] = geojson.features[0].model_dump(mode="json") + assert feature_model_dump["geometry"]["type"] == mock_raw_geojson_multipoint["type"] + assert ( + feature_model_dump["geometry"]["coordinates"] + == mock_raw_geojson_multipoint["coordinates"] + ) + + +def test_geojson_model_serializes_multipolygon_to_feature_collection( + mock_raw_geojson_multipolygon: Dict[str, Any], +) -> None: + """Test that `GeoJson` serializes multipolygon to feature collection correctly.""" + geojson: GeoJson = GeoJson(**mock_raw_geojson_multipolygon) + + assert_valid_geojson(geojson) + + feature_model_dump: Dict[str, Any] = geojson.features[0].model_dump(mode="json") + assert ( + feature_model_dump["geometry"]["type"] == mock_raw_geojson_multipolygon["type"] + ) + assert ( + feature_model_dump["geometry"]["coordinates"] + == mock_raw_geojson_multipolygon["coordinates"] + ) + + +def test_geojson_model_serializes_point_to_feature_collection( + mock_raw_geojson_point: Dict[str, Any], +) -> None: + """Test that `GeoJson` serializes point to feature collection correctly.""" + geojson: GeoJson = GeoJson(**mock_raw_geojson_point) + + assert_valid_geojson(geojson) + + feature_model_dump: Dict[str, Any] = geojson.features[0].model_dump(mode="json") + assert feature_model_dump["geometry"]["type"] == mock_raw_geojson_point["type"] + assert ( + feature_model_dump["geometry"]["coordinates"] + == mock_raw_geojson_point["coordinates"] + ) + + +def test_geojson_model_serializes_polygon_to_feature_collection( + mock_raw_geojson_polygon: Dict[str, Any], +) -> None: + """Test that `GeoJson` serializes polygon to feature collection correctly.""" + geojson: GeoJson = GeoJson(**mock_raw_geojson_polygon) + + assert_valid_geojson(geojson) + + feature_model_dump: Dict[str, Any] = geojson.features[0].model_dump(mode="json") + assert feature_model_dump["geometry"]["type"] == mock_raw_geojson_polygon["type"] + assert ( + feature_model_dump["geometry"]["coordinates"] + == mock_raw_geojson_polygon["coordinates"] + ) + + +@pytest.mark.parametrize( + "invalid_data", + [ + {}, + {"type": "invalid_type"}, + {"type": "Point"}, + {"coordinates": []}, + {"geometries": []}, + {"features": []}, + {"geometry": []}, + {"type": "FeatureCollection", "features": None}, + {"type": "Feature", "geometry": None}, + {"type": "GeometryCollection", "geometries": None}, + {"type": "Point", "coordinates": None}, + ], +) +def test_geojson_model_serializes_invalid_data_raises_validation_error( + invalid_data: Any, +) -> None: + """Test that `GeoJson` serializes raises a `ValidationError` on invalid data.""" + with pytest.raises(ValidationError): + GeoJson(**invalid_data) + + +def test_geojson_model_serialize_deserialize_roundtrips( + mock_raw_geojson_feature_collection: Dict[str, Any], +) -> None: + """Test that `GeoJson` can be serialized and deserialized without loss.""" + original: GeoJson = GeoJson(**mock_raw_geojson_feature_collection) + reconstructed: GeoJson = GeoJson(**original.model_dump(mode="json")) + + assert_valid_geojson(original) + assert_valid_geojson(reconstructed) + assert original.model_dump(mode="json") == reconstructed.model_dump(mode="json") + + +def test_geojson_model_geo_interface_serialize_deserialize_roundtrips( + mock_raw_geojson_feature_collection: Dict[str, Any], +) -> None: + """Test that `GeoJson` can be serialized and deserialized from `__geo_interface__` without loss.""" + original: GeoJson = GeoJson(**mock_raw_geojson_feature_collection) + reconstructed: GeoJson = GeoJson(**original.__geo_interface__) + + assert_valid_geojson(original) + assert_valid_geojson(reconstructed) + assert original.__geo_interface__ == reconstructed.__geo_interface__ + + +def test_geojson_model_to_geometry_serializes_to_geometry( + mock_raw_geojson_features: List[Dict[str, Any]], +) -> None: + """Test that `GeoJson` serializes to geometry correctly.""" + geojsons: List[GeoJson] = [ + GeoJson(**mock_raw_geojson_feature) + for mock_raw_geojson_feature in mock_raw_geojson_features + ] + + for geojson in geojsons: + assert_valid_geojson(geojson) + + geometry: Geometry = geojson.to_geometry() + + assert geometry is not None + assert isinstance( + geometry, + ( + Point, + MultiPoint, + LineString, + MultiLineString, + Polygon, + MultiPolygon, + GeometryCollection, + ), + ) + assert geometry.__geo_interface__ is not None + assert isinstance(geometry.__geo_interface__, dict) + + +def test_geojson_model_to_geometry_serializes_to_geometrycollection( + mock_raw_geojson_feature_collections: List[Dict[str, Any]], +) -> None: + """Test that `GeoJson` serializes to geometry collection correctly.""" + geojsons: List[GeoJson] = [ + GeoJson(**mock_raw_geojson_feature_collection) + for mock_raw_geojson_feature_collection in mock_raw_geojson_feature_collections + ] + + for geojson in geojsons: + assert_valid_geojson(geojson) + + geometry: Geometry = geojson.to_geometry() + + assert geometry is not None + assert isinstance(geometry, GeometryCollection) + assert geometry.__geo_interface__ is not None + assert isinstance(geometry.__geo_interface__, dict) + + +@pytest.mark.parametrize( + "empty_geometry_source", + [ + {"type": "FeatureCollection", "features": []}, + { + "type": "FeatureCollection", + "features": [{"type": "Feature", "geometry": None, "properties": None}], + }, + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": {"type": "GeometryCollection", "geometries": []}, + "properties": None, + } + ], + }, + ], +) +def test_geojson_model_serializes_to_geometry_empty_geometries_raises_value_error( + empty_geometry_source: Dict[str, Any], +) -> None: + """Test that `GeoJson` serializes to geometry raises a `ValueError` if contains no geometries.""" + geojson: GeoJson = GeoJson(**empty_geometry_source) + + with pytest.raises(ValueError): + geojson.to_geometry() + + +@pytest.mark.parametrize( + "invalid_source", + [ + None, # Unsupported type + "null", # None JSON-string + 12345, # Unsupported type + "not a path or json", # Unsupported value + "path/to/invalid/spatial/file.shp", # Does not point to a file + "path/to/invalid/spatial/file.json", # Does not point to a file + ], +) +def test_geojson_model_from_file_or_geojson_invalid_source_raises_value_error( + invalid_source: Any, +) -> None: + """Test that `GeoJson` from file or geojson raises a `ValueError` on invalid source.""" + with pytest.raises(ValueError): + GeoJson.from_file_or_geojson(source=invalid_source) + + +@pytest.mark.parametrize( + "invalid_geojson_source", + [ + {}, + "{}", + {"type": "invalid_type"}, + '{"type": "invalid_type"}', + {"type": "Point"}, + '{"type": "Point"}', + {"coordinates": []}, + '{"coordinates": []}', + {"geometries": []}, + '{"geometries": []}', + {"features": []}, + '{"features": []}', + {"geometry": []}, + '{"geometry": []}', + {"type": "FeatureCollection", "features": None}, + '{"type": "FeatureCollection", "features": null}', + {"type": "Feature", "geometry": None}, + '{"type": "Feature", "geometry": null}', + {"type": "GeometryCollection", "geometries": None}, + '{"type": "GeometryCollection", "geometries": null}', + {"type": "Point", "coordinates": None}, + '{"type": "Point", "coordinates": null}', + ], +) +def test_geojson_model_from_file_or_geojson_invalid_geojson_source_raises_validation_error( + invalid_geojson_source: Any, +) -> None: + """Test that `GeoJson` from file or geojson raises a `ValidationError` on invalid geojson source.""" + with pytest.raises(ValidationError): + GeoJson.from_file_or_geojson(source=invalid_geojson_source) + + +@pytest.mark.parametrize( + "filename", + [ + "tests/fixtures/base/geojson/geojson_featurecollection.json", + Path("tests/fixtures/base/geojson/geojson_featurecollection.json"), + "tests/fixtures/base/shapefiles/geojson_featurecollection.shp", + Path("tests/fixtures/base/shapefiles/geojson_featurecollection.shp"), + ], +) +def test_geojson_model_from_file_or_geojson_create_from_valid_file_path_source( + filename: Union[str, Path], +) -> None: + """Test that `GeoJson` can be created from a valid file `Path` source correctly.""" + geojson: GeoJson = GeoJson.from_file_or_geojson(source=filename) + + assert_valid_geojson(geojson) + + +def test_geojson_model_from_file_or_geojson_create_from_valid_geojson_dict_source( + mock_raw_geojson_feature_collection: Dict[str, Any], +) -> None: + """Test that `GeoJson` can be created from a valid geojson `dict` source correctly.""" + geojson: GeoJson = GeoJson.from_file_or_geojson( + source=mock_raw_geojson_feature_collection + ) + + assert_valid_geojson(geojson) + + +def test_geojson_model_from_file_or_geojson_create_from_valid_geojson_string_source( + mock_raw_geojson_feature_collection: Dict[str, Any], +) -> None: + """Test that `GeoJson` can be created from a valid geojson string source correctly.""" + geojson: GeoJson = GeoJson.from_file_or_geojson( + source=json.dumps(mock_raw_geojson_feature_collection) + ) + + assert_valid_geojson(geojson) + + +def test_geojson_model_from_file_or_geojson_create_from_valid_geojson_protocol_source( + mock_raw_geojson_feature_collection: Dict[str, Any], + mock_raw_geojson_polygon: Dict[str, Any], +) -> None: + """Test that `GeoJson` can be created from a valid geojson protocol source correctly.""" + sources = [ + GeoJson(**mock_raw_geojson_feature_collection), # GeoJson + gpd.GeoDataFrame.from_features( + {**mock_raw_geojson_feature_collection} + ), # GeoDataFrame + shapely.geometry.shape({**mock_raw_geojson_polygon}), # shapely geometry + ] + + for source in sources: + geojson: GeoJson = GeoJson.from_file_or_geojson(source=source) + + assert_valid_geojson(geojson) + + +@pytest.mark.parametrize( + "invalid_value", + [ + None, # Unsupported type + "null", # None JSON-string + 12345, # Unsupported type + "not a path or json", # Unsupported value + "path/to/invalid/spatial/file.shp", # Does not point to a file + "path/to/invalid/spatial/file.json", # Does not point to a file + ], +) +def test_geojson_model_parse_file_path_invalid_value_raises_validation_error( + invalid_value: Any, +) -> None: + """Test that `GeoJson` parse file path raises a `ValidationError` on invalid file path.""" + with pytest.raises(ValidationError): + GeoJson.parse_file_path(value=invalid_value, on_invalid=OnInvalid.RAISE) + + +@pytest.mark.parametrize( + "invalid_value", + [ + None, # Unsupported type + "null", # None JSON-string + 12345, # Unsupported type + "not a path or json", # Unsupported value + "path/to/invalid/spatial/file.shp", # Does not point to a file + "path/to/invalid/spatial/file.json", # Does not point to a file + ], +) +def test_geojson_model_parse_geojson_json_invalid_value_raises_validation_error( + invalid_value: Any, +) -> None: + """Test that `GeoJson` parse geojson json-string raises a `ValidationError` on invalid JSON-string.""" + with pytest.raises(ValidationError): + GeoJson.parse_geojson_json(value=invalid_value, on_invalid=OnInvalid.RAISE) + + +def test_geojson_model_supports_geo_interface_protocol_isinstance_runtime_check() -> ( + None +): + """Test that `SupportsGeoJsonInterface` supports isinstance runtime check correctly.""" + + class SampleGeo: + """A sample model for testing `SupportsGeoJsonInterface` behavior.""" + + @property + def __geo_interface__(self) -> Dict[str, Any]: + return {"type": "Point", "coordinates": [0, 0]} + + sample_geo: SampleGeo = SampleGeo() + assert isinstance(sample_geo, SupportsGeoJsonInterface) + assert sample_geo.__geo_interface__ is not None + assert isinstance(sample_geo.__geo_interface__, dict) diff --git a/tests/fixtures/base/geojson/geojson_feature.json b/tests/fixtures/base/geojson/geojson_feature.json new file mode 100644 index 0000000..4ba9569 --- /dev/null +++ b/tests/fixtures/base/geojson/geojson_feature.json @@ -0,0 +1,16 @@ +{ + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [120.36621093749999, 26.725986812271756], + [122.36572265625, 26.725986812271756], + [122.36572265625, 28.323724553546015], + [120.36621093749999, 28.323724553546015], + [120.36621093749999, 26.725986812271756] + ] + ] + } +} diff --git a/tests/fixtures/base/geojson/geojson_featurecollection.json b/tests/fixtures/base/geojson/geojson_featurecollection.json new file mode 100644 index 0000000..a2259f0 --- /dev/null +++ b/tests/fixtures/base/geojson/geojson_featurecollection.json @@ -0,0 +1,21 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [120.36621093749999, 26.725986812271756], + [122.36572265625, 26.725986812271756], + [122.36572265625, 28.323724553546015], + [120.36621093749999, 28.323724553546015], + [120.36621093749999, 26.725986812271756] + ] + ] + } + } + ] +} diff --git a/tests/fixtures/base/geojson/geojson_geometrycollection.json b/tests/fixtures/base/geojson/geojson_geometrycollection.json new file mode 100644 index 0000000..8847d77 --- /dev/null +++ b/tests/fixtures/base/geojson/geojson_geometrycollection.json @@ -0,0 +1,31 @@ +{ + "type": "GeometryCollection", + "geometries": [ + { + "type": "Polygon", + "coordinates": [ + [ + [120.36621093749999, 26.725986812271756], + [122.36572265625, 26.725986812271756], + [122.36572265625, 28.323724553546015], + [120.36621093749999, 28.323724553546015], + [120.36621093749999, 26.725986812271756] + ] + ] + }, + { + "type": "LineString", + "coordinates": [ + [120.36621093749999, 26.725986812271756], + [122.36572265625, 26.725986812271756], + [122.36572265625, 28.323724553546015], + [120.36621093749999, 28.323724553546015], + [120.36621093749999, 26.725986812271756] + ] + }, + { + "type": "Point", + "coordinates": [120.36621093749999, 26.725986812271756] + } + ] +} diff --git a/tests/fixtures/base/geojson/geojson_linestring.json b/tests/fixtures/base/geojson/geojson_linestring.json new file mode 100644 index 0000000..49958b3 --- /dev/null +++ b/tests/fixtures/base/geojson/geojson_linestring.json @@ -0,0 +1,10 @@ +{ + "type": "LineString", + "coordinates": [ + [120.36621093749999, 26.725986812271756], + [122.36572265625, 26.725986812271756], + [122.36572265625, 28.323724553546015], + [120.36621093749999, 28.323724553546015], + [120.36621093749999, 26.725986812271756] + ] +} diff --git a/tests/fixtures/base/geojson/geojson_multilinestring.json b/tests/fixtures/base/geojson/geojson_multilinestring.json new file mode 100644 index 0000000..b65e072 --- /dev/null +++ b/tests/fixtures/base/geojson/geojson_multilinestring.json @@ -0,0 +1,12 @@ +{ + "type": "MultiLineString", + "coordinates": [ + [ + [120.36621093749999, 26.725986812271756], + [122.36572265625, 26.725986812271756], + [122.36572265625, 28.323724553546015], + [120.36621093749999, 28.323724553546015], + [120.36621093749999, 26.725986812271756] + ] + ] +} diff --git a/tests/fixtures/base/geojson/geojson_multipoint.json b/tests/fixtures/base/geojson/geojson_multipoint.json new file mode 100644 index 0000000..1cca08a --- /dev/null +++ b/tests/fixtures/base/geojson/geojson_multipoint.json @@ -0,0 +1,10 @@ +{ + "type": "MultiPoint", + "coordinates": [ + [120.36621093749999, 26.725986812271756], + [122.36572265625, 26.725986812271756], + [122.36572265625, 28.323724553546015], + [120.36621093749999, 28.323724553546015], + [120.36621093749999, 26.725986812271756] + ] +} diff --git a/tests/fixtures/base/geojson/geojson_multipolygon.json b/tests/fixtures/base/geojson/geojson_multipolygon.json new file mode 100644 index 0000000..fb57a47 --- /dev/null +++ b/tests/fixtures/base/geojson/geojson_multipolygon.json @@ -0,0 +1,14 @@ +{ + "type": "MultiPolygon", + "coordinates": [ + [ + [ + [120.36621093749999, 26.725986812271756], + [122.36572265625, 26.725986812271756], + [122.36572265625, 28.323724553546015], + [120.36621093749999, 28.323724553546015], + [120.36621093749999, 26.725986812271756] + ] + ] + ] +} diff --git a/tests/fixtures/base/geojson/geojson_point.json b/tests/fixtures/base/geojson/geojson_point.json new file mode 100644 index 0000000..f9c2b23 --- /dev/null +++ b/tests/fixtures/base/geojson/geojson_point.json @@ -0,0 +1,4 @@ +{ + "type": "Point", + "coordinates": [120.36621093749999, 26.725986812271756] +} diff --git a/tests/fixtures/base/geojson/geojson_polygon.json b/tests/fixtures/base/geojson/geojson_polygon.json new file mode 100644 index 0000000..3c685e2 --- /dev/null +++ b/tests/fixtures/base/geojson/geojson_polygon.json @@ -0,0 +1,12 @@ +{ + "type": "Polygon", + "coordinates": [ + [ + [120.36621093749999, 26.725986812271756], + [122.36572265625, 26.725986812271756], + [122.36572265625, 28.323724553546015], + [120.36621093749999, 28.323724553546015], + [120.36621093749999, 26.725986812271756] + ] + ] +} diff --git a/tests/fixtures/base/shapefiles/geojson_featurecollection.cpg b/tests/fixtures/base/shapefiles/geojson_featurecollection.cpg new file mode 100644 index 0000000..7edc66b --- /dev/null +++ b/tests/fixtures/base/shapefiles/geojson_featurecollection.cpg @@ -0,0 +1 @@ +UTF-8 diff --git a/tests/fixtures/base/shapefiles/geojson_featurecollection.dbf b/tests/fixtures/base/shapefiles/geojson_featurecollection.dbf new file mode 100644 index 0000000..07eda97 Binary files /dev/null and b/tests/fixtures/base/shapefiles/geojson_featurecollection.dbf differ diff --git a/tests/fixtures/base/shapefiles/geojson_featurecollection.prj b/tests/fixtures/base/shapefiles/geojson_featurecollection.prj new file mode 100644 index 0000000..0ae685b --- /dev/null +++ b/tests/fixtures/base/shapefiles/geojson_featurecollection.prj @@ -0,0 +1 @@ +GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137.0,298.257223563]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]] diff --git a/tests/fixtures/base/shapefiles/geojson_featurecollection.shp b/tests/fixtures/base/shapefiles/geojson_featurecollection.shp new file mode 100644 index 0000000..cff6b47 Binary files /dev/null and b/tests/fixtures/base/shapefiles/geojson_featurecollection.shp differ diff --git a/tests/fixtures/base/shapefiles/geojson_featurecollection.shx b/tests/fixtures/base/shapefiles/geojson_featurecollection.shx new file mode 100644 index 0000000..3c26b14 Binary files /dev/null and b/tests/fixtures/base/shapefiles/geojson_featurecollection.shx differ