Skip to content

Commit 08f3d1b

Browse files
Merge pull request #143 from developmentseed/CustomSerializer
add custom serializer to match GeoJSON spec
2 parents 74a8cf0 + 2fda6cc commit 08f3d1b

File tree

10 files changed

+408
-83
lines changed

10 files changed

+408
-83
lines changed

CHANGELOG.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Note: Minor version `0.X.0` update might break the API, It's recommanded to pin
1919

2020
### Changed
2121

22+
* update pydantic requirement to `~=2.0`
23+
2224
* update pydantic `FeatureCollection` generic model to allow named features in the generated schemas.
2325

2426
```python
@@ -29,8 +31,6 @@ Note: Minor version `0.X.0` update might break the API, It's recommanded to pin
2931
FeatureCollection[Feature[Geometry, Properties]]
3032
```
3133

32-
* update pydantic requirement to `~=2.0`
33-
3434
* raise `ValueError` in `geomtries.parse_geometry_obj` instead of `ValidationError`
3535

3636
```python
@@ -43,6 +43,19 @@ Note: Minor version `0.X.0` update might break the API, It's recommanded to pin
4343
>> ValueError("Unknown type: This type")
4444
```
4545

46+
* update JSON serializer to exclude null `bbox` and `id`
47+
48+
```python
49+
# before
50+
Point(type="Point", coordinates=[0, 0]).json()
51+
>> '{"type":"Point","coordinates":[0.0,0.0],"bbox":null}'
52+
53+
# now
54+
Point(type="Point", coordinates=[0, 0]).model_dump_json()
55+
>> '{"type":"Point","coordinates":[0.0,0.0]}'
56+
```
57+
58+
4659
## [0.6.3] - 2023-07-02
4760

4861
* limit pydantic requirement to `~=1.0`

geojson_pydantic/base.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""pydantic BaseModel for GeoJSON objects."""
2+
from __future__ import annotations
3+
4+
from typing import Any, Dict, List, Optional, Set
5+
6+
from pydantic import BaseModel, SerializationInfo, field_validator, model_serializer
7+
8+
from geojson_pydantic.types import BBox
9+
10+
11+
class _GeoJsonBase(BaseModel):
12+
bbox: Optional[BBox] = None
13+
14+
# These fields will not be included when serializing in json mode
15+
# `.model_dump_json()` or `.model_dump(mode="json")`
16+
__geojson_exclude_if_none__: Set[str] = {"bbox"}
17+
18+
@property
19+
def __geo_interface__(self) -> Dict[str, Any]:
20+
"""GeoJSON-like protocol for geo-spatial (GIS) vector data.
21+
22+
ref: https://gist.github.com/sgillies/2217756#__geo_interface
23+
"""
24+
return self.model_dump(mode="json")
25+
26+
@field_validator("bbox")
27+
def validate_bbox(cls, bbox: Optional[BBox]) -> Optional[BBox]:
28+
"""Validate BBox values are ordered correctly."""
29+
# If bbox is None, there is nothing to validate.
30+
if bbox is None:
31+
return None
32+
33+
# A list to store any errors found so we can raise them all at once.
34+
errors: List[str] = []
35+
36+
# Determine where the second position starts. 2 for 2D, 3 for 3D.
37+
offset = len(bbox) // 2
38+
39+
# Check X
40+
if bbox[0] > bbox[offset]:
41+
errors.append(f"Min X ({bbox[0]}) must be <= Max X ({bbox[offset]}).")
42+
# Check Y
43+
if bbox[1] > bbox[1 + offset]:
44+
errors.append(f"Min Y ({bbox[1]}) must be <= Max Y ({bbox[1 + offset]}).")
45+
# If 3D, check Z values.
46+
if offset > 2 and bbox[2] > bbox[2 + offset]:
47+
errors.append(f"Min Z ({bbox[2]}) must be <= Max Z ({bbox[2 + offset]}).")
48+
49+
# Raise any errors found.
50+
if errors:
51+
raise ValueError("Invalid BBox. Error(s): " + " ".join(errors))
52+
53+
return bbox
54+
55+
@model_serializer(when_used="json", mode="wrap")
56+
def clean_model(self, serializer: Any, _info: SerializationInfo) -> Dict[str, Any]:
57+
"""Custom Model serializer to match the GeoJSON specification.
58+
59+
Used to remove fields which are optional but cannot be null values.
60+
"""
61+
# This seems like the best way to have the least amount of unexpected consequences.
62+
# We want to avoid forcing values in `exclude_none` or `exclude_unset` which could
63+
# cause issues or unexpected behavior for downstream users.
64+
data: Dict[str, Any] = serializer(self)
65+
for field in self.__geojson_exclude_if_none__:
66+
if field in data and data[field] is None:
67+
del data[field]
68+
return data

geojson_pydantic/features.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,22 @@
44

55
from pydantic import BaseModel, Field, StrictInt, StrictStr, field_validator
66

7-
from geojson_pydantic.geo_interface import GeoInterfaceMixin
7+
from geojson_pydantic.base import _GeoJsonBase
88
from geojson_pydantic.geometries import Geometry
9-
from geojson_pydantic.types import BBox, validate_bbox
109

1110
Props = TypeVar("Props", bound=Union[Dict[str, Any], BaseModel])
1211
Geom = TypeVar("Geom", bound=Geometry)
1312

1413

15-
class Feature(BaseModel, Generic[Geom, Props], GeoInterfaceMixin):
14+
class Feature(_GeoJsonBase, Generic[Geom, Props]):
1615
"""Feature Model"""
1716

1817
type: Literal["Feature"]
1918
geometry: Union[Geom, None] = Field(...)
2019
properties: Union[Props, None] = Field(...)
2120
id: Optional[Union[StrictInt, StrictStr]] = None
22-
bbox: Optional[BBox] = None
2321

24-
_validate_bbox = field_validator("bbox")(validate_bbox)
22+
__geojson_exclude_if_none__ = {"bbox", "id"}
2523

2624
@field_validator("geometry", mode="before")
2725
def set_geometry(cls, geometry: Any) -> Any:
@@ -35,12 +33,11 @@ def set_geometry(cls, geometry: Any) -> Any:
3533
Feat = TypeVar("Feat", bound=Feature)
3634

3735

38-
class FeatureCollection(BaseModel, Generic[Feat], GeoInterfaceMixin):
36+
class FeatureCollection(_GeoJsonBase, Generic[Feat]):
3937
"""FeatureCollection Model"""
4038

4139
type: Literal["FeatureCollection"]
4240
features: List[Feat]
43-
bbox: Optional[BBox] = None
4441

4542
def __iter__(self) -> Iterator[Feat]: # type: ignore [override]
4643
"""iterate over features"""
@@ -53,5 +50,3 @@ def __len__(self) -> int:
5350
def __getitem__(self, index: int) -> Feat:
5451
"""get feature at a given index"""
5552
return self.features[index]
56-
57-
_validate_bbox = field_validator("bbox")(validate_bbox)

geojson_pydantic/geo_interface.py

Lines changed: 0 additions & 23 deletions
This file was deleted.

geojson_pydantic/geometries.py

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,20 @@
33

44
import abc
55
import warnings
6-
from typing import Any, Iterator, List, Literal, Optional, Union
6+
from typing import Any, Iterator, List, Literal, Union
77

8-
from pydantic import BaseModel, Field, field_validator
8+
from pydantic import Field, field_validator
99
from typing_extensions import Annotated
1010

11-
from geojson_pydantic.geo_interface import GeoInterfaceMixin
11+
from geojson_pydantic.base import _GeoJsonBase
1212
from geojson_pydantic.types import (
13-
BBox,
1413
LinearRing,
1514
LineStringCoords,
1615
MultiLineStringCoords,
1716
MultiPointCoords,
1817
MultiPolygonCoords,
1918
PolygonCoords,
2019
Position,
21-
validate_bbox,
2220
)
2321

2422

@@ -72,12 +70,11 @@ def _polygons_wkt_coordinates(
7270
)
7371

7472

75-
class _GeometryBase(BaseModel, abc.ABC, GeoInterfaceMixin):
73+
class _GeometryBase(_GeoJsonBase, abc.ABC):
7674
"""Base class for geometry models"""
7775

7876
type: str
7977
coordinates: Any
80-
bbox: Optional[BBox] = None
8178

8279
@abc.abstractmethod
8380
def __wkt_coordinates__(self, coordinates: Any, force_z: bool) -> str:
@@ -107,8 +104,6 @@ def wkt(self) -> str:
107104

108105
return wkt
109106

110-
_validate_bbox = field_validator("bbox")(validate_bbox)
111-
112107

113108
class Point(_GeometryBase):
114109
"""Point Model"""
@@ -249,12 +244,11 @@ def check_closure(cls, coordinates: List) -> List:
249244
return coordinates
250245

251246

252-
class GeometryCollection(BaseModel, GeoInterfaceMixin):
247+
class GeometryCollection(_GeoJsonBase):
253248
"""GeometryCollection Model"""
254249

255250
type: Literal["GeometryCollection"]
256251
geometries: List[Geometry]
257-
bbox: Optional[BBox] = None
258252

259253
def __iter__(self) -> Iterator[Geometry]: # type: ignore [override]
260254
"""iterate over geometries"""
@@ -286,8 +280,6 @@ def wkt(self) -> str:
286280
z = " Z " if "Z" in geometries else " "
287281
return f"{self.type.upper()}{z}{geometries}"
288282

289-
_validate_bbox = field_validator("bbox")(validate_bbox)
290-
291283
@field_validator("geometries")
292284
def check_geometries(cls, geometries: List) -> List:
293285
"""Add warnings for conditions the spec does not explicitly forbid."""

geojson_pydantic/types.py

Lines changed: 1 addition & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,15 @@
11
"""Types for geojson_pydantic models"""
22

3-
from typing import List, Optional, Tuple, TypeVar, Union
3+
from typing import List, Tuple, Union
44

55
from pydantic import Field
66
from typing_extensions import Annotated
77

8-
T = TypeVar("T")
9-
108
BBox = Union[
119
Tuple[float, float, float, float], # 2D bbox
1210
Tuple[float, float, float, float, float, float], # 3D bbox
1311
]
1412

15-
16-
def validate_bbox(bbox: Optional[BBox]) -> Optional[BBox]:
17-
"""Validate BBox values are ordered correctly."""
18-
# If bbox is None, there is nothing to validate.
19-
if bbox is None:
20-
return None
21-
22-
# A list to store any errors found so we can raise them all at once.
23-
errors: List[str] = []
24-
25-
# Determine where the second position starts. 2 for 2D, 3 for 3D.
26-
offset = len(bbox) // 2
27-
28-
# Check X
29-
if bbox[0] > bbox[offset]:
30-
errors.append(f"Min X ({bbox[0]}) must be <= Max X ({bbox[offset]}).")
31-
# Check Y
32-
if bbox[1] > bbox[1 + offset]:
33-
errors.append(f"Min Y ({bbox[1]}) must be <= Max Y ({bbox[1 + offset]}).")
34-
# If 3D, check Z values.
35-
if offset > 2 and bbox[2] > bbox[2 + offset]:
36-
errors.append(f"Min Z ({bbox[2]}) must be <= Max Z ({bbox[2 + offset]}).")
37-
38-
# Raise any errors found.
39-
if errors:
40-
raise ValueError("Invalid BBox. Error(s): " + " ".join(errors))
41-
42-
return bbox
43-
44-
4513
Position = Union[Tuple[float, float], Tuple[float, float, float]]
4614

4715
# Coordinate arrays

pyproject.toml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,4 @@ ignore = [
8888
]
8989

9090
[tool.ruff.per-file-ignores]
91-
"tests/test_geometries.py" = ["D1"]
92-
"tests/test_features.py" = ["D1"]
93-
"tests/test_package.py" = ["D1"]
91+
"tests/*.py" = ["D1"]

0 commit comments

Comments
 (0)