Skip to content

Commit 2fda6cc

Browse files
authored
Merge pull request #144 from eseglem/CustomSerializer
Create a base model and generic serialization.
2 parents 50a09b3 + c6712a2 commit 2fda6cc

File tree

7 files changed

+179
-141
lines changed

7 files changed

+179
-141
lines changed

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: 6 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,48 +2,24 @@
22

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

5-
from pydantic import (
6-
BaseModel,
7-
Field,
8-
StrictInt,
9-
StrictStr,
10-
field_validator,
11-
model_serializer,
12-
)
13-
14-
from geojson_pydantic.geo_interface import GeoInterfaceMixin
5+
from pydantic import BaseModel, Field, StrictInt, StrictStr, field_validator
6+
7+
from geojson_pydantic.base import _GeoJsonBase
158
from geojson_pydantic.geometries import Geometry
16-
from geojson_pydantic.types import BBox, validate_bbox
179

1810
Props = TypeVar("Props", bound=Union[Dict[str, Any], BaseModel])
1911
Geom = TypeVar("Geom", bound=Geometry)
2012

2113

22-
class Feature(BaseModel, Generic[Geom, Props], GeoInterfaceMixin):
14+
class Feature(_GeoJsonBase, Generic[Geom, Props]):
2315
"""Feature Model"""
2416

2517
type: Literal["Feature"]
2618
geometry: Union[Geom, None] = Field(...)
2719
properties: Union[Props, None] = Field(...)
2820
id: Optional[Union[StrictInt, StrictStr]] = None
29-
bbox: Optional[BBox] = None
30-
31-
_validate_bbox = field_validator("bbox")(validate_bbox)
32-
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
4521

46-
return model
22+
__geojson_exclude_if_none__ = {"bbox", "id"}
4723

4824
@field_validator("geometry", mode="before")
4925
def set_geometry(cls, geometry: Any) -> Any:
@@ -57,24 +33,11 @@ def set_geometry(cls, geometry: Any) -> Any:
5733
Feat = TypeVar("Feat", bound=Feature)
5834

5935

60-
class FeatureCollection(BaseModel, Generic[Feat], GeoInterfaceMixin):
36+
class FeatureCollection(_GeoJsonBase, Generic[Feat]):
6137
"""FeatureCollection Model"""
6238

6339
type: Literal["FeatureCollection"]
6440
features: List[Feat]
65-
bbox: Optional[BBox] = None
66-
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
7841

7942
def __iter__(self) -> Iterator[Feat]: # type: ignore [override]
8043
"""iterate over features"""
@@ -87,5 +50,3 @@ def __len__(self) -> int:
8750
def __getitem__(self, index: int) -> Feat:
8851
"""get feature at a given index"""
8952
return self.features[index]
90-
91-
_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 & 37 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, Dict, Iterator, List, Literal, Optional, Union
6+
from typing import Any, Iterator, List, Literal, Union
77

8-
from pydantic import BaseModel, Field, field_validator, model_serializer
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,24 +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
81-
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
9378

9479
@abc.abstractmethod
9580
def __wkt_coordinates__(self, coordinates: Any, force_z: bool) -> str:
@@ -119,8 +104,6 @@ def wkt(self) -> str:
119104

120105
return wkt
121106

122-
_validate_bbox = field_validator("bbox")(validate_bbox)
123-
124107

125108
class Point(_GeometryBase):
126109
"""Point Model"""
@@ -261,24 +244,11 @@ def check_closure(cls, coordinates: List) -> List:
261244
return coordinates
262245

263246

264-
class GeometryCollection(BaseModel, GeoInterfaceMixin):
247+
class GeometryCollection(_GeoJsonBase):
265248
"""GeometryCollection Model"""
266249

267250
type: Literal["GeometryCollection"]
268251
geometries: List[Geometry]
269-
bbox: Optional[BBox] = None
270-
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
282252

283253
def __iter__(self) -> Iterator[Geometry]: # type: ignore [override]
284254
"""iterate over geometries"""
@@ -310,8 +280,6 @@ def wkt(self) -> str:
310280
z = " Z " if "Z" in geometries else " "
311281
return f"{self.type.upper()}{z}{geometries}"
312282

313-
_validate_bbox = field_validator("bbox")(validate_bbox)
314-
315283
@field_validator("geometries")
316284
def check_geometries(cls, geometries: List) -> List:
317285
"""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)