Skip to content

Commit 50a09b3

Browse files
committed
ad custom serializer to match GeoJSON spec
1 parent 74a8cf0 commit 50a09b3

File tree

5 files changed

+292
-5
lines changed

5 files changed

+292
-5
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/features.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,14 @@
22

33
from typing import Any, Dict, Generic, Iterator, List, Literal, Optional, TypeVar, Union
44

5-
from pydantic import BaseModel, Field, StrictInt, StrictStr, field_validator
5+
from pydantic import (
6+
BaseModel,
7+
Field,
8+
StrictInt,
9+
StrictStr,
10+
field_validator,
11+
model_serializer,
12+
)
613

714
from geojson_pydantic.geo_interface import GeoInterfaceMixin
815
from geojson_pydantic.geometries import Geometry
@@ -23,6 +30,21 @@ class Feature(BaseModel, Generic[Geom, Props], GeoInterfaceMixin):
2330

2431
_validate_bbox = field_validator("bbox")(validate_bbox)
2532

33+
@model_serializer(when_used="json")
34+
def ser_model(self) -> Dict[str, Any]:
35+
"""Custom Model serializer to match the GeoJSON specification."""
36+
model: Dict[str, Any] = {
37+
"type": self.type,
38+
"geometry": self.geometry,
39+
"properties": self.properties,
40+
}
41+
if self.id is not None:
42+
model["id"] = self.id
43+
if self.bbox:
44+
model["bbox"] = self.bbox
45+
46+
return model
47+
2648
@field_validator("geometry", mode="before")
2749
def set_geometry(cls, geometry: Any) -> Any:
2850
"""set geometry from geo interface or input"""
@@ -42,6 +64,18 @@ class FeatureCollection(BaseModel, Generic[Feat], GeoInterfaceMixin):
4264
features: List[Feat]
4365
bbox: Optional[BBox] = None
4466

67+
@model_serializer(when_used="json")
68+
def ser_model(self) -> Dict[str, Any]:
69+
"""Custom Model serializer to match the GeoJSON specification."""
70+
model: Dict[str, Any] = {
71+
"type": self.type,
72+
"features": self.features,
73+
}
74+
if self.bbox:
75+
model["bbox"] = self.bbox
76+
77+
return model
78+
4579
def __iter__(self) -> Iterator[Feat]: # type: ignore [override]
4680
"""iterate over features"""
4781
return iter(self.features)

geojson_pydantic/geometries.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33

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

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

1111
from geojson_pydantic.geo_interface import GeoInterfaceMixin
@@ -79,6 +79,18 @@ class _GeometryBase(BaseModel, abc.ABC, GeoInterfaceMixin):
7979
coordinates: Any
8080
bbox: Optional[BBox] = None
8181

82+
@model_serializer(when_used="json")
83+
def ser_model(self) -> Dict[str, Any]:
84+
"""Custom Model serializer to match the GeoJSON specification."""
85+
model: Dict[str, Any] = {
86+
"type": self.type,
87+
"coordinates": self.coordinates,
88+
}
89+
if self.bbox:
90+
model["bbox"] = self.bbox
91+
92+
return model
93+
8294
@abc.abstractmethod
8395
def __wkt_coordinates__(self, coordinates: Any, force_z: bool) -> str:
8496
"""return WKT coordinates."""
@@ -256,6 +268,18 @@ class GeometryCollection(BaseModel, GeoInterfaceMixin):
256268
geometries: List[Geometry]
257269
bbox: Optional[BBox] = None
258270

271+
@model_serializer(when_used="json")
272+
def ser_model(self) -> Dict[str, Any]:
273+
"""Custom Model serializer to match the GeoJSON specification."""
274+
model: Dict[str, Any] = {
275+
"type": self.type,
276+
"geometries": self.geometries,
277+
}
278+
if self.bbox:
279+
model["bbox"] = self.bbox
280+
281+
return model
282+
259283
def __iter__(self) -> Iterator[Geometry]: # type: ignore [override]
260284
"""iterate over geometries"""
261285
return iter(self.geometries)

tests/test_features.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from random import randint
23
from typing import Any, Dict
34
from uuid import uuid4
@@ -302,3 +303,107 @@ def test_feature_validation_error_count():
302303
except ValidationError as e:
303304
assert e.error_count() == 1
304305
raise
306+
307+
308+
def test_feature_serializer():
309+
f = Feature(
310+
**{
311+
"type": "Feature",
312+
"geometry": {
313+
"type": "Polygon",
314+
"coordinates": coordinates,
315+
},
316+
"properties": {},
317+
"id": "Yo",
318+
"bbox": [13.38272, 52.46385, 13.42786, 52.48445],
319+
}
320+
)
321+
assert "bbox" in f.model_dump()
322+
assert "id" in f.model_dump()
323+
324+
feat_ser = json.loads(f.model_dump_json())
325+
assert "bbox" in feat_ser
326+
assert "id" in feat_ser
327+
assert "bbox" not in feat_ser["geometry"]
328+
329+
f = Feature(
330+
**{
331+
"type": "Feature",
332+
"geometry": {
333+
"type": "Polygon",
334+
"coordinates": coordinates,
335+
},
336+
"properties": {},
337+
}
338+
)
339+
assert "bbox" in f.model_dump()
340+
341+
feat_ser = json.loads(f.model_dump_json())
342+
assert "bbox" not in feat_ser
343+
assert "id" not in feat_ser
344+
assert "bbox" not in feat_ser["geometry"]
345+
346+
f = Feature(
347+
**{
348+
"type": "Feature",
349+
"geometry": {
350+
"type": "Polygon",
351+
"coordinates": coordinates,
352+
"bbox": [13.38272, 52.46385, 13.42786, 52.48445],
353+
},
354+
"properties": {},
355+
}
356+
)
357+
feat_ser = json.loads(f.model_dump_json())
358+
assert "bbox" not in feat_ser
359+
assert "id" not in feat_ser
360+
assert "bbox" in feat_ser["geometry"]
361+
362+
363+
def test_feature_collection_serializer():
364+
fc = FeatureCollection(
365+
**{
366+
"type": "FeatureCollection",
367+
"features": [
368+
{
369+
"type": "Feature",
370+
"geometry": {
371+
"type": "Polygon",
372+
"coordinates": coordinates,
373+
"bbox": [13.38272, 52.46385, 13.42786, 52.48445],
374+
},
375+
"properties": {},
376+
"bbox": [13.38272, 52.46385, 13.42786, 52.48445],
377+
}
378+
],
379+
"bbox": [13.38272, 52.46385, 13.42786, 52.48445],
380+
}
381+
)
382+
assert "bbox" in fc.model_dump()
383+
384+
featcoll_ser = json.loads(fc.model_dump_json())
385+
assert "bbox" in featcoll_ser
386+
assert "bbox" in featcoll_ser["features"][0]
387+
assert "bbox" in featcoll_ser["features"][0]["geometry"]
388+
389+
fc = FeatureCollection(
390+
**{
391+
"type": "FeatureCollection",
392+
"features": [
393+
{
394+
"type": "Feature",
395+
"geometry": {
396+
"type": "Polygon",
397+
"coordinates": coordinates,
398+
},
399+
"properties": {},
400+
}
401+
],
402+
}
403+
)
404+
assert "bbox" in fc.model_dump()
405+
406+
featcoll_ser = json.loads(fc.model_dump_json())
407+
assert "bbox" not in featcoll_ser
408+
assert "bbox" not in featcoll_ser["features"][0]
409+
assert "bbox" not in featcoll_ser["features"][0]["geometry"]

tests/test_geometries.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import json
2+
13
import pytest
24
import shapely
35
from pydantic import ValidationError
46

57
from geojson_pydantic.geometries import (
8+
Geometry,
69
GeometryCollection,
710
LineString,
811
MultiLineString,
@@ -761,3 +764,111 @@ def test_wkt(wkt: str):
761764
# Use Shapely to parse the input WKT so we know it is parsable by other tools.
762765
# Then load it into a Geometry and ensure the output WKT is the same as the input.
763766
assert parse_geometry_obj(shapely.from_wkt(wkt).__geo_interface__).wkt == wkt
767+
768+
769+
@pytest.mark.parametrize(
770+
"geom",
771+
(
772+
Point(type="Point", coordinates=[0, 0], bbox=[0, 0, 0, 0]),
773+
Point(type="Point", coordinates=[0, 0]),
774+
MultiPoint(type="MultiPoint", coordinates=[(0.0, 0.0)], bbox=[0, 0, 0, 0]),
775+
MultiPoint(type="MultiPoint", coordinates=[(0.0, 0.0)]),
776+
LineString(
777+
type="LineString", coordinates=[(0.0, 0.0), (1.0, 1.0)], bbox=[0, 0, 1, 1]
778+
),
779+
LineString(type="LineString", coordinates=[(0.0, 0.0), (1.0, 1.0)]),
780+
MultiLineString(
781+
type="MultiLineString",
782+
coordinates=[[(0.0, 0.0), (1.0, 1.0)]],
783+
bbox=[0, 0, 1, 1],
784+
),
785+
MultiLineString(type="MultiLineString", coordinates=[[(0.0, 0.0), (1.0, 1.0)]]),
786+
Polygon(
787+
type="Polygon",
788+
coordinates=[[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (1.0, 2.0)]],
789+
bbox=[1.0, 2.0, 5.0, 6.0],
790+
),
791+
Polygon(
792+
type="Polygon",
793+
coordinates=[[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (1.0, 2.0)]],
794+
),
795+
MultiPolygon(
796+
type="MultiPolygon",
797+
coordinates=[[[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (1.0, 2.0)]]],
798+
bbox=[1.0, 2.0, 5.0, 6.0],
799+
),
800+
MultiPolygon(
801+
type="MultiPolygon",
802+
coordinates=[[[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0), (1.0, 2.0)]]],
803+
),
804+
),
805+
)
806+
def test_geometry_serializer(geom: Geometry):
807+
# bbox should always be in the dictionary version of the model
808+
# but should only be in the JSON version if not None
809+
assert "bbox" in geom.model_dump()
810+
if geom.bbox is not None:
811+
assert "bbox" in geom.model_dump_json()
812+
else:
813+
assert "bbox" not in geom.model_dump_json()
814+
815+
816+
def test_geometry_collection_serializer():
817+
geom = GeometryCollection(
818+
type="GeometryCollection",
819+
geometries=[
820+
Point(type="Point", coordinates=[0, 0]),
821+
LineString(type="LineString", coordinates=[(0.0, 0.0), (1.0, 1.0)]),
822+
],
823+
)
824+
# bbox will be in the Dict
825+
assert "bbox" in geom.model_dump()
826+
assert "bbox" in geom.model_dump()["geometries"][0]
827+
828+
# bbox should not be in any Geometry nor at the top level
829+
geom_ser = json.loads(geom.model_dump_json())
830+
assert "bbox" not in geom_ser
831+
assert "bbox" not in geom_ser["geometries"][0]
832+
assert "bbox" not in geom_ser["geometries"][0]
833+
834+
geom = GeometryCollection(
835+
type="GeometryCollection",
836+
geometries=[
837+
Point(type="Point", coordinates=[0, 0], bbox=[0, 0, 0, 0]),
838+
LineString(type="LineString", coordinates=[(0.0, 0.0), (1.0, 1.0)]),
839+
],
840+
)
841+
# bbox not in the top level but in the first geometry (point)
842+
geom_ser = json.loads(geom.model_dump_json())
843+
assert "bbox" not in geom_ser
844+
assert "bbox" in geom_ser["geometries"][0]
845+
assert "bbox" not in geom_ser["geometries"][1]
846+
847+
geom = GeometryCollection(
848+
type="GeometryCollection",
849+
geometries=[
850+
Point(type="Point", coordinates=[0, 0], bbox=[0, 0, 0, 0]),
851+
LineString(
852+
type="LineString",
853+
coordinates=[(0.0, 0.0), (1.0, 1.0)],
854+
bbox=[0, 0, 1, 1],
855+
),
856+
],
857+
)
858+
geom_ser = json.loads(geom.model_dump_json())
859+
assert "bbox" not in geom_ser
860+
assert "bbox" in geom_ser["geometries"][0]
861+
assert "bbox" in geom_ser["geometries"][1]
862+
863+
geom = GeometryCollection(
864+
type="GeometryCollection",
865+
geometries=[
866+
Point(type="Point", coordinates=[0, 0]),
867+
LineString(type="LineString", coordinates=[(0.0, 0.0), (1.0, 1.0)]),
868+
],
869+
bbox=[0, 0, 1, 1],
870+
)
871+
geom_ser = json.loads(geom.model_dump_json())
872+
assert "bbox" in geom_ser
873+
assert "bbox" not in geom_ser["geometries"][0]
874+
assert "bbox" not in geom_ser["geometries"][1]

0 commit comments

Comments
 (0)