diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2dc499..a771354 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + python-version: ['3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8493f21..0b567a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.6.0] - TBD + +- Remove python 3.7 support +- Enforce required keys and avoid defaults. This aim to follow the geojson specification to the letter. + + ```python + # Before + Feature(geometry=Point(coordinates=(0,0))) + + # Now + Feature( + type="Feature", + geometry=Point( + type="Point", + coordinates=(0,0) + ), + properties=None, + ) + ``` + +### Fixed + +- Do not validates arbitrary dictionaries. Make `Type` a mandatory key for objects (https://github.com/developmentseed/geojson-pydantic/pull/94) + ## [0.5.0] - 2022-12-16 ### Added diff --git a/geojson_pydantic/features.py b/geojson_pydantic/features.py index 8df4606..d15ee40 100644 --- a/geojson_pydantic/features.py +++ b/geojson_pydantic/features.py @@ -1,6 +1,6 @@ """pydantic models for GeoJSON Feature objects.""" -from typing import Any, Dict, Generic, Iterator, List, Optional, TypeVar, Union +from typing import Any, Dict, Generic, Iterator, List, Literal, Optional, TypeVar, Union from pydantic import BaseModel, Field, validator from pydantic.generics import GenericModel @@ -15,9 +15,9 @@ class Feature(GenericModel, Generic[Geom, Props]): """Feature Model""" - type: str = Field(default="Feature", const=True) - geometry: Optional[Geom] = None - properties: Optional[Props] = None + type: Literal["Feature"] + geometry: Union[Geom, None] = Field(...) + properties: Union[Props, None] = Field(...) id: Optional[str] = None bbox: Optional[BBox] = None @@ -31,6 +31,7 @@ def set_geometry(cls, geometry: Any) -> Any: """set geometry from geo interface or input""" if hasattr(geometry, "__geo_interface__"): return geometry.__geo_interface__ + return geometry @property @@ -44,23 +45,22 @@ def __geo_interface__(self) -> Dict[str, Any]: "geometry": self.geometry.__geo_interface__ if self.geometry is not None else None, + "properties": self.properties, } + if self.bbox: geo["bbox"] = self.bbox if self.id: geo["id"] = self.id - if self.properties: - geo["properties"] = self.properties - return geo class FeatureCollection(GenericModel, Generic[Geom, Props]): """FeatureCollection Model""" - type: str = Field(default="FeatureCollection", const=True) + type: Literal["FeatureCollection"] features: List[Feature[Geom, Props]] bbox: Optional[BBox] = None diff --git a/geojson_pydantic/geometries.py b/geojson_pydantic/geometries.py index e6368b0..b41fdf6 100644 --- a/geojson_pydantic/geometries.py +++ b/geojson_pydantic/geometries.py @@ -1,9 +1,9 @@ """pydantic models for GeoJSON Geometry objects.""" import abc -from typing import Any, Dict, Iterator, List, Union +from typing import Any, Dict, Iterator, List, Literal, Union -from pydantic import BaseModel, Field, ValidationError, validator +from pydantic import BaseModel, ValidationError, validator from pydantic.error_wrappers import ErrorWrapper from geojson_pydantic.types import ( @@ -56,7 +56,7 @@ def wkt(self) -> str: class Point(_GeometryBase): """Point Model""" - type: str = Field(default="Point", const=True) + type: Literal["Point"] coordinates: Position @property @@ -71,7 +71,7 @@ def _wkt_inset(self) -> str: class MultiPoint(_GeometryBase): """MultiPoint Model""" - type: str = Field(default="MultiPoint", const=True) + type: Literal["MultiPoint"] coordinates: MultiPointCoords @property @@ -80,14 +80,14 @@ def _wkt_inset(self) -> str: @property def _wkt_coordinates(self) -> str: - points = [Point(coordinates=p) for p in self.coordinates] + points = [Point(type="Point", coordinates=p) for p in self.coordinates] return ", ".join(point._wkt_coordinates for point in points) class LineString(_GeometryBase): """LineString Model""" - type: str = Field(default="LineString", const=True) + type: Literal["LineString"] coordinates: LineStringCoords @property @@ -96,14 +96,14 @@ def _wkt_inset(self) -> str: @property def _wkt_coordinates(self) -> str: - points = [Point(coordinates=p) for p in self.coordinates] + points = [Point(type="Point", coordinates=p) for p in self.coordinates] return ", ".join(point._wkt_coordinates for point in points) class MultiLineString(_GeometryBase): """MultiLineString Model""" - type: str = Field(default="MultiLineString", const=True) + type: Literal["MultiLineString"] coordinates: MultiLineStringCoords @property @@ -112,7 +112,9 @@ def _wkt_inset(self) -> str: @property def _wkt_coordinates(self) -> str: - lines = [LineString(coordinates=line) for line in self.coordinates] + lines = [ + LineString(type="LineString", coordinates=line) for line in self.coordinates + ] return ",".join(f"({line._wkt_coordinates})" for line in lines) @@ -131,7 +133,7 @@ def check_closure(cls, coordinates: List) -> List: class Polygon(_GeometryBase): """Polygon Model""" - type: str = Field(default="Polygon", const=True) + type: Literal["Polygon"] coordinates: PolygonCoords @validator("coordinates") @@ -161,10 +163,10 @@ def _wkt_inset(self) -> str: @property def _wkt_coordinates(self) -> str: ic = "".join( - f", ({LinearRingGeom(coordinates=interior)._wkt_coordinates})" + f", ({LinearRingGeom(type='LineString', coordinates=interior)._wkt_coordinates})" for interior in self.interiors ) - return f"({LinearRingGeom(coordinates=self.exterior)._wkt_coordinates}){ic}" + return f"({LinearRingGeom(type='LineString', coordinates=self.exterior)._wkt_coordinates}){ic}" @classmethod def from_bounds( @@ -172,16 +174,17 @@ def from_bounds( ) -> "Polygon": """Create a Polygon geometry from a boundingbox.""" return cls( + type="Polygon", coordinates=[ [(xmin, ymin), (xmax, ymin), (xmax, ymax), (xmin, ymax), (xmin, ymin)] - ] + ], ) class MultiPolygon(_GeometryBase): """MultiPolygon Model""" - type: str = Field(default="MultiPolygon", const=True) + type: Literal["MultiPolygon"] coordinates: MultiPolygonCoords @property @@ -190,7 +193,9 @@ def _wkt_inset(self) -> str: @property def _wkt_coordinates(self) -> str: - polygons = [Polygon(coordinates=poly) for poly in self.coordinates] + polygons = [ + Polygon(type="Polygon", coordinates=poly) for poly in self.coordinates + ] return ",".join(f"({poly._wkt_coordinates})" for poly in polygons) @@ -200,7 +205,7 @@ def _wkt_coordinates(self) -> str: class GeometryCollection(BaseModel): """GeometryCollection Model""" - type: str = Field(default="GeometryCollection", const=True) + type: Literal["GeometryCollection"] geometries: List[Geometry] def __iter__(self) -> Iterator[Geometry]: # type: ignore [override] diff --git a/pyproject.toml b/pyproject.toml index 7707352..fa3cd69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "geojson-pydantic" description = "Pydantic data models for the GeoJSON spec." readme = "README.md" -requires-python = ">=3.7" +requires-python = ">=3.8" license = {file = "LICENSE"} authors = [ {name = "Drew Bollinger", email = "drew@developmentseed.org"}, @@ -12,7 +12,6 @@ classifiers = [ "Intended Audience :: Information Technology", "Intended Audience :: Science/Research", "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", diff --git a/tests/test_features.py b/tests/test_features.py index f28483e..a61faba 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -66,14 +66,18 @@ class GenericProperties(BaseModel): def test_feature_collection_iteration(): """test if feature collection is iterable""" - gc = FeatureCollection(features=[test_feature, test_feature]) + gc = FeatureCollection( + type="FeatureCollection", features=[test_feature, test_feature] + ) assert hasattr(gc, "__geo_interface__") iter(gc) def test_geometry_collection_iteration(): """test if feature collection is iterable""" - gc = FeatureCollection(features=[test_feature_geometry_collection]) + gc = FeatureCollection( + type="FeatureCollection", features=[test_feature_geometry_collection] + ) assert hasattr(gc, "__geo_interface__") iter(gc) @@ -152,7 +156,7 @@ def test_generic_properties_should_raise_for_string(): def test_feature_collection_generic(): fc = FeatureCollection[Polygon, GenericProperties]( - features=[test_feature, test_feature] + type="FeatureCollection", features=[test_feature, test_feature] ) assert len(fc) == 2 assert type(fc[0].properties) == GenericProperties @@ -163,7 +167,7 @@ def test_geo_interface_protocol(): class Pointy: __geo_interface__ = {"type": "Point", "coordinates": (0.0, 0.0)} - feat = Feature(geometry=Pointy()) + feat = Feature(type="Feature", geometry=Pointy(), properties={}) assert feat.geometry.dict() == Pointy.__geo_interface__ @@ -178,7 +182,30 @@ def test_feature_geo_interface_with_null_geometry(): def test_feature_collection_geo_interface_with_null_geometry(): - fc = FeatureCollection(features=[test_feature_geom_null, test_feature]) + fc = FeatureCollection( + type="FeatureCollection", features=[test_feature_geom_null, test_feature] + ) assert "bbox" not in fc.__geo_interface__ assert "bbox" not in fc.__geo_interface__["features"][0] assert "bbox" in fc.__geo_interface__["features"][1] + + +def test_feature_validation(): + """Test default.""" + assert Feature(type="Feature", properties=None, geometry=None) + + with pytest.raises(ValidationError): + # should be type=Feature + Feature(type="feature", properties=None, geometry=None) + + with pytest.raises(ValidationError): + # missing type + Feature(properties=None, geometry=None) + + with pytest.raises(ValidationError): + # missing properties + Feature(type="Feature", geometry=None) + + with pytest.raises(ValidationError): + # missing geometry + Feature(type="Feature", properties=None) diff --git a/tests/test_geometries.py b/tests/test_geometries.py index 89c8524..5cbd17d 100644 --- a/tests/test_geometries.py +++ b/tests/test_geometries.py @@ -30,7 +30,7 @@ def test_point_valid_coordinates(coordinates): """ Two or three number elements as coordinates shold be okay """ - p = Point(coordinates=coordinates) + p = Point(type="Point", coordinates=coordinates) assert p.type == "Point" assert p.coordinates == coordinates assert hasattr(p, "__geo_interface__") @@ -45,7 +45,7 @@ def test_point_invalid_coordinates(coordinates): Too few or to many elements should not, nor weird data types """ with pytest.raises(ValidationError): - Point(coordinates=coordinates) + Point(type="Point", coordinates=coordinates) @pytest.mark.parametrize( @@ -61,7 +61,7 @@ def test_multi_point_valid_coordinates(coordinates): """ Two or three number elements as coordinates shold be okay """ - p = MultiPoint(coordinates=coordinates) + p = MultiPoint(type="MultiPoint", coordinates=coordinates) assert p.type == "MultiPoint" assert p.coordinates == coordinates assert hasattr(p, "__geo_interface__") @@ -77,7 +77,7 @@ def test_multi_point_invalid_coordinates(coordinates): Too few or to many elements should not, nor weird data types """ with pytest.raises(ValidationError): - MultiPoint(coordinates=coordinates) + MultiPoint(type="MultiPoint", coordinates=coordinates) @pytest.mark.parametrize( @@ -92,7 +92,7 @@ def test_line_string_valid_coordinates(coordinates): """ A list of two coordinates or more should be okay """ - linestring = LineString(coordinates=coordinates) + linestring = LineString(type="LineString", coordinates=coordinates) assert linestring.type == "LineString" assert linestring.coordinates == coordinates assert hasattr(linestring, "__geo_interface__") @@ -105,7 +105,7 @@ def test_line_string_invalid_coordinates(coordinates): But we don't accept non-list inputs, too few coordinates, or bogus coordinates """ with pytest.raises(ValidationError): - LineString(coordinates=coordinates) + LineString(type="LineString", coordinates=coordinates) @pytest.mark.parametrize( @@ -120,7 +120,7 @@ def test_multi_line_string_valid_coordinates(coordinates): """ A list of two coordinates or more should be okay """ - multilinestring = MultiLineString(coordinates=coordinates) + multilinestring = MultiLineString(type="MultiLineString", coordinates=coordinates) assert multilinestring.type == "MultiLineString" assert multilinestring.coordinates == coordinates assert hasattr(multilinestring, "__geo_interface__") @@ -135,7 +135,7 @@ def test_multi_line_string_invalid_coordinates(coordinates): But we don't accept non-list inputs, too few coordinates, or bogus coordinates """ with pytest.raises(ValidationError): - MultiLineString(coordinates=coordinates) + MultiLineString(type="MultiLineString", coordinates=coordinates) @pytest.mark.parametrize( @@ -149,7 +149,7 @@ def test_polygon_valid_coordinates(coordinates): """ Should accept lists of linear rings """ - polygon = Polygon(coordinates=coordinates) + polygon = Polygon(type="Polygon", coordinates=coordinates) assert polygon.type == "Polygon" assert polygon.coordinates == coordinates assert hasattr(polygon, "__geo_interface__") @@ -161,10 +161,11 @@ def test_polygon_valid_coordinates(coordinates): def test_polygon_with_holes(): """Check interior and exterior rings.""" polygon = Polygon( + type="Polygon", coordinates=[ [(0.0, 0.0), (0.0, 10.0), (10.0, 10.0), (10.0, 0.0), (0.0, 0.0)], [(2.0, 2.0), (2.0, 4.0), (4.0, 4.0), (4.0, 2.0), (2.0, 2.0)], - ] + ], ) assert polygon.type == "Polygon" @@ -193,12 +194,13 @@ def test_polygon_invalid_coordinates(coordinates): - If not all elements are linear rings """ with pytest.raises(ValidationError): - Polygon(coordinates=coordinates) + Polygon(type="Polygon", coordinates=coordinates) def test_multi_polygon(): """Should accept sequence of polygons.""" multi_polygon = MultiPolygon( + type="MultiPolygon", coordinates=[ [ [ @@ -216,7 +218,7 @@ def test_multi_polygon(): (2.1, 2.1, 4.0), ], ] - ] + ], ) assert multi_polygon.type == "MultiPolygon" @@ -226,14 +228,14 @@ def test_multi_polygon(): def test_parse_geometry_obj_point(): assert parse_geometry_obj({"type": "Point", "coordinates": [102.0, 0.5]}) == Point( - coordinates=(102.0, 0.5) + type="Point", coordinates=(102.0, 0.5) ) def test_parse_geometry_obj_multi_point(): assert parse_geometry_obj( {"type": "MultiPoint", "coordinates": [[100.0, 0.0], [101.0, 1.0]]} - ) == MultiPoint(coordinates=[(100.0, 0.0), (101.0, 1.0)]) + ) == MultiPoint(type="MultiPoint", coordinates=[(100.0, 0.0), (101.0, 1.0)]) def test_parse_geometry_obj_line_string(): @@ -243,7 +245,8 @@ def test_parse_geometry_obj_line_string(): "coordinates": [[102.0, 0.0], [103.0, 1.0], [104.0, 0.0], [105.0, 1.0]], } ) == LineString( - coordinates=[(102.0, 0.0), (103.0, 1.0), (104.0, 0.0), (105.0, 1.0)] + type="LineString", + coordinates=[(102.0, 0.0), (103.0, 1.0), (104.0, 0.0), (105.0, 1.0)], ) @@ -254,7 +257,8 @@ def test_parse_geometry_obj_multi_line_string(): "coordinates": [[[100.0, 0.0], [101.0, 1.0]], [[102.0, 2.0], [103.0, 3.0]]], } ) == MultiLineString( - coordinates=[[(100.0, 0.0), (101.0, 1.0)], [(102.0, 2.0), (103.0, 3.0)]] + type="MultiLineString", + coordinates=[[(100.0, 0.0), (101.0, 1.0)], [(102.0, 2.0), (103.0, 3.0)]], ) @@ -267,9 +271,10 @@ def test_parse_geometry_obj_polygon(): ], } ) == Polygon( + type="Polygon", coordinates=[ [(100.0, 0.0), (101.0, 0.0), (101.0, 1.0), (100.0, 1.0), (100.0, 0.0)] - ] + ], ) @@ -306,6 +311,7 @@ def test_parse_geometry_obj_multi_polygon(): ], } ) == MultiPolygon( + type="MultiPolygon", coordinates=[ [[(102.0, 2.0), (103.0, 2.0), (103.0, 3.0), (102.0, 3.0), (102.0, 2.0)]], [ @@ -340,8 +346,8 @@ def test_parse_geometry_obj_invalid_point(): ) def test_geometry_collection_iteration(coordinates): """test if geometry collection is iterable""" - polygon = Polygon(coordinates=coordinates) - gc = GeometryCollection(geometries=[polygon, polygon]) + polygon = Polygon(type="Polygon", coordinates=coordinates) + gc = GeometryCollection(type="GeometryCollection", geometries=[polygon, polygon]) assert hasattr(gc, "__geo_interface__") assert_wkt_equivalence(gc) iter(gc) @@ -352,8 +358,8 @@ def test_geometry_collection_iteration(coordinates): ) def test_len_geometry_collection(polygon): """test if GeometryCollection return self leng""" - polygon = Polygon(coordinates=polygon) - gc = GeometryCollection(geometries=[polygon, polygon]) + polygon = Polygon(type="Polygon", coordinates=polygon) + gc = GeometryCollection(type="GeometryCollection", geometries=[polygon, polygon]) assert_wkt_equivalence(gc) assert len(gc) == 2 @@ -363,8 +369,8 @@ def test_len_geometry_collection(polygon): ) def test_getitem_geometry_collection(polygon): """test if GeometryCollection return self leng""" - polygon = Polygon(coordinates=polygon) - gc = GeometryCollection(geometries=[polygon, polygon]) + polygon = Polygon(type="Polygon", coordinates=polygon) + gc = GeometryCollection(type="GeometryCollection", geometries=[polygon, polygon]) assert_wkt_equivalence(gc) item = gc[0] assert item == gc[0] @@ -373,7 +379,9 @@ def test_getitem_geometry_collection(polygon): def test_polygon_from_bounds(): """Result from `from_bounds` class method should be the same.""" coordinates = [[(1.0, 2.0), (3.0, 2.0), (3.0, 4.0), (1.0, 4.0), (1.0, 2.0)]] - assert Polygon(coordinates=coordinates) == Polygon.from_bounds(1.0, 2.0, 3.0, 4.0) + assert Polygon(type="Polygon", coordinates=coordinates) == Polygon.from_bounds( + 1.0, 2.0, 3.0, 4.0 + ) def test_wkt_name(): @@ -383,5 +391,6 @@ class PointType(Point): ... assert ( - PointType(coordinates=(1.01, 2.01)).wkt == Point(coordinates=(1.01, 2.01)).wkt + PointType(type="Point", coordinates=(1.01, 2.01)).wkt + == Point(type="Point", coordinates=(1.01, 2.01)).wkt )