In this article, I will explain how to create a pydantic model to validate and create polygons for GIS (geographic information systems).
We start by creating constraints for Latitude and Longitude.
from pydantic import BaseModel, ConstrainedFloat
class Latitude(ConstrainedFloat):
ge = -90
le = 90
class Longitude(ConstrainedFloat):
ge = -180
le = 180
These types will come in handy. You can, for example, use them to create a Coordinates
model.
class Coordinates(BaseModel):
lat: Latitude
lng: Longitude
Now, the polygon. For this demonstration we will do like and use Shapely, which takes one set of coordinates to define a surface, and then none or several other sets to define holes. Each set must have at least 3 coordinates. So we get:
from typing import List, Tuple
from pydantic import conlist
class MyModel(BaseModel):
polygon: conlist(conlist(Tuple[Longitude, Latitude], min_items=3), min_items=1)
MyModel.parse_obj({
"polygon": [
# shell
[(45, -100), (45, -101), (46, -101)],
# hole
[(45.3, -100.3), (45.3, -100.6), (44.6, -101.6)]
]
})
Now, to make the type reusable, we need to declare a new template and use the __root__
property as follows:
from typing import Any
class Polygon(BaseModel):
__root__: conlist(conlist(Tuple[Longitude, Latitude], min_items=3), min_items=1)
def __init__(self, **data: Any):
super().__init__(**data)
class MyModel(BaseModel):
polygon: Polygon
To take it a step further, why not initialize the Shapely.geometry.Polygon
object as soon as the model is created? Note that I renamed Polygon
to RawPolygon
to avoid any conflict with shapely.
from pydantic import Field, PrivateAttr
from shapely.geometry import Polygon
class RawPolygon(BaseModel):
__root__: conlist(conlist(Tuple[Longitude, Latitude], min_items=3), min_items=1)
_polygon: Polygon = PrivateAttr()
def __init__(self, **data: Any):
super().__init__(**data)
self._polygon = Polygon(data["__root__"][0], data["__root__"][1:])
@property
def polygon(self) -> Polygon:
return self._polygon
class MyModel(BaseModel):
raw_polygon: RawPolygon = Field(..., alias="polygon")
@property
def polygon(self) -> Polygon:
return self.raw_polygon.polygon
And our initial example still works in addition to the directly available polygon.
m = MyModel.parse_obj({
"polygon": [
# polygon
[(45, -100), (45, -101), (46, -101)],
# hole
[(45.3, -100.3), (45.3, -100.6), (44.6, -101.6)]
]
})
print(m.polygon)
#> POLYGON ((45 -100, 45 -101, 46 -101, 45 -100), (45.3 -100.3, 45.3 -100.6, 44.6 -101.6, 45.3 -100.3))
Full Code
from typing import Any, Tuple
from pydantic import BaseModel, ConstrainedFloat, Field, PrivateAttr, conlist
from shapely.geometry import Polygon
class Latitude(ConstrainedFloat):
ge = -90
le = 90
class Longitude(ConstrainedFloat):
ge = -180
le = 180
class RawPolygon(BaseModel):
__root__: conlist(conlist(Tuple[Longitude, Latitude], min_items=3), min_items=1)
_polygon: Polygon = PrivateAttr()
def __init__(self, **data: Any):
super().__init__(**data)
self._polygon = Polygon(data["__root__"][0], data["__root__"][1:])
@property
def polygon(self) -> Polygon:
return self._polygon
class MyModel(BaseModel):
raw_polygon: RawPolygon = Field(..., alias="polygon")
@property
def polygon(self) -> Polygon:
return self.raw_polygon.polygon