Skip to content

Commit f704dfa

Browse files
Merge pull request #130 from developmentseed/pydantic2.0
update for pydantic 2.0
2 parents 8a07ab8 + d45d09e commit f704dfa

File tree

10 files changed

+179
-64
lines changed

10 files changed

+179
-64
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ repos:
2323
args: ["--fix"]
2424

2525
- repo: https://github.com/pre-commit/mirrors-mypy
26-
rev: v0.991
26+
rev: v1.4.1
2727
hooks:
2828
- id: mypy
2929
language_version: python
3030
# No reason to run if only tests have changed. They intentionally break typing.
3131
exclude: tests/.*
3232
additional_dependencies:
33-
- pydantic~=1.0
33+
- pydantic~=2.0

CHANGELOG.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,31 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
66

77
Note: Minor version `0.X.0` update might break the API, It's recommanded to pin geojson-pydantic to minor version: `geojson-pydantic>=0.6,<0.7`
88

9+
## [unreleased]
10+
11+
### Added
12+
13+
* more tests for `GeometryCollection` warnings
14+
15+
### changed
16+
17+
* update pydantic requirement to `~=2.0`
18+
19+
* raise `ValueError` in `geomtries.parse_geometry_obj` instead of `ValidationError`
20+
21+
```python
22+
# before
23+
parse_geometry_obj({"type": "This type", "obviously": "doesn't exist"})
24+
>> ValidationError
25+
26+
# now
27+
parse_geometry_obj({"type": "This type", "obviously": "doesn't exist"})
28+
>> ValueError("Unknown type: This type")
29+
```
30+
931
## [0.6.3] - 2023-07-02
1032

11-
* limit pydantic requirement to `~=1.0``
33+
* limit pydantic requirement to `~=1.0`
1234

1335
## [0.6.2] - 2023-05-16
1436

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ from typing import Optional
146146
MyPointFeatureModel = Feature[Optional[Point], Dict]
147147

148148
assert MyPointFeatureModel(type="Feature", geometry=None, properties={}).geometry is None
149-
assert MyPointFeatureModel(type="Feature", geometry=Point(coordinates=(0,0)), properties={}).geometry is not None
149+
assert MyPointFeatureModel(type="Feature", geometry=Point(type="Point", coordinates=(0,0)), properties={}).geometry is not None
150150
```
151151

152152
And now with constrained properties
@@ -157,7 +157,7 @@ from pydantic import BaseModel, constr
157157

158158
# Define a Feature model with Geometry as `Point` and Properties as a constrained Model
159159
class MyProps(BaseModel):
160-
name: constr(regex=r'^(drew|vincent)$')
160+
name: constr(pattern=r'^(drew|vincent)$')
161161

162162
MyPointFeatureModel = Feature[Point, MyProps]
163163

geojson_pydantic/features.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,17 @@
22

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

5-
from pydantic import BaseModel, Field, StrictInt, StrictStr, validator
6-
from pydantic.generics import GenericModel
5+
from pydantic import BaseModel, Field, StrictInt, StrictStr, field_validator
76

87
from geojson_pydantic.geo_interface import GeoInterfaceMixin
9-
from geojson_pydantic.geometries import Geometry, GeometryCollection
8+
from geojson_pydantic.geometries import Geometry
109
from geojson_pydantic.types import BBox, validate_bbox
1110

1211
Props = TypeVar("Props", bound=Union[Dict[str, Any], BaseModel])
13-
Geom = TypeVar("Geom", bound=Union[Geometry, GeometryCollection])
12+
Geom = TypeVar("Geom", bound=Geometry)
1413

1514

16-
class Feature(GenericModel, Generic[Geom, Props], GeoInterfaceMixin):
15+
class Feature(BaseModel, Generic[Geom, Props], GeoInterfaceMixin):
1716
"""Feature Model"""
1817

1918
type: Literal["Feature"]
@@ -22,9 +21,9 @@ class Feature(GenericModel, Generic[Geom, Props], GeoInterfaceMixin):
2221
id: Optional[Union[StrictInt, StrictStr]] = None
2322
bbox: Optional[BBox] = None
2423

25-
_validate_bbox = validator("bbox", allow_reuse=True)(validate_bbox)
24+
_validate_bbox = field_validator("bbox")(validate_bbox)
2625

27-
@validator("geometry", pre=True, always=True)
26+
@field_validator("geometry", mode="before")
2827
def set_geometry(cls, geometry: Any) -> Any:
2928
"""set geometry from geo interface or input"""
3029
if hasattr(geometry, "__geo_interface__"):
@@ -33,7 +32,7 @@ def set_geometry(cls, geometry: Any) -> Any:
3332
return geometry
3433

3534

36-
class FeatureCollection(GenericModel, Generic[Geom, Props], GeoInterfaceMixin):
35+
class FeatureCollection(BaseModel, Generic[Geom, Props], GeoInterfaceMixin):
3736
"""FeatureCollection Model"""
3837

3938
type: Literal["FeatureCollection"]
@@ -52,4 +51,4 @@ def __getitem__(self, index: int) -> Feature:
5251
"""get feature at a given index"""
5352
return self.features[index]
5453

55-
_validate_bbox = validator("bbox", allow_reuse=True)(validate_bbox)
54+
_validate_bbox = field_validator("bbox")(validate_bbox)

geojson_pydantic/geo_interface.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
from typing import Any, Dict, Protocol
44

55

6-
class _DictProtocol(Protocol):
6+
class _ModelDumpProtocol(Protocol):
77
"""Protocol for use as the type of self in the mixin."""
88

9-
def dict(self, *, exclude_unset: bool, **args: Any) -> Dict[str, Any]:
9+
def model_dump(self, *, exclude_unset: bool, **args: Any) -> Dict[str, Any]:
1010
"""Define a dict function so the mixin knows it exists."""
1111
...
1212

@@ -15,9 +15,9 @@ class GeoInterfaceMixin:
1515
"""Mixin for __geo_interface__ on GeoJSON objects."""
1616

1717
@property
18-
def __geo_interface__(self: _DictProtocol) -> Dict[str, Any]:
18+
def __geo_interface__(self: _ModelDumpProtocol) -> Dict[str, Any]:
1919
"""GeoJSON-like protocol for geo-spatial (GIS) vector data.
2020
2121
ref: https://gist.github.com/sgillies/2217756#__geo_interface
2222
"""
23-
return self.dict(exclude_unset=True)
23+
return self.model_dump(exclude_unset=True)

geojson_pydantic/geometries.py

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
import warnings
66
from typing import Any, Iterator, List, Literal, Optional, Union
77

8-
from pydantic import BaseModel, Field, ValidationError, validator
9-
from pydantic.error_wrappers import ErrorWrapper
8+
from pydantic import BaseModel, Field, field_validator
109
from typing_extensions import Annotated
1110

1211
from geojson_pydantic.geo_interface import GeoInterfaceMixin
@@ -108,7 +107,7 @@ def wkt(self) -> str:
108107

109108
return wkt
110109

111-
_validate_bbox = validator("bbox", allow_reuse=True)(validate_bbox)
110+
_validate_bbox = field_validator("bbox")(validate_bbox)
112111

113112

114113
class Point(_GeometryBase):
@@ -185,7 +184,7 @@ def __wkt_coordinates__(self, coordinates: Any, force_z: bool) -> str:
185184
"""return WKT coordinates."""
186185
return _lines_wtk_coordinates(coordinates, force_z)
187186

188-
@validator("coordinates")
187+
@field_validator("coordinates")
189188
def check_closure(cls, coordinates: List) -> List:
190189
"""Validate that Polygon is closed (first and last coordinate are the same)."""
191190
if any(ring[-1] != ring[0] for ring in coordinates):
@@ -238,7 +237,7 @@ def has_z(self) -> bool:
238237
"""Checks if any coordinates have a Z value."""
239238
return any(_lines_has_z(polygon) for polygon in self.coordinates)
240239

241-
@validator("coordinates")
240+
@field_validator("coordinates")
242241
def check_closure(cls, coordinates: List) -> List:
243242
"""Validate that Polygon is closed (first and last coordinate are the same)."""
244243
if any(ring[-1] != ring[0] for polygon in coordinates for ring in polygon):
@@ -247,28 +246,22 @@ def check_closure(cls, coordinates: List) -> List:
247246
return coordinates
248247

249248

250-
Geometry = Annotated[
251-
Union[Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon],
252-
Field(discriminator="type"),
253-
]
254-
255-
256249
class GeometryCollection(BaseModel, GeoInterfaceMixin):
257250
"""GeometryCollection Model"""
258251

259252
type: Literal["GeometryCollection"]
260-
geometries: List[Union[Geometry, GeometryCollection]]
253+
geometries: List[Geometry]
261254
bbox: Optional[BBox] = None
262255

263-
def __iter__(self) -> Iterator[Union[Geometry, GeometryCollection]]: # type: ignore [override]
256+
def __iter__(self) -> Iterator[Geometry]: # type: ignore [override]
264257
"""iterate over geometries"""
265258
return iter(self.geometries)
266259

267260
def __len__(self) -> int:
268261
"""return geometries length"""
269262
return len(self.geometries)
270263

271-
def __getitem__(self, index: int) -> Union[Geometry, GeometryCollection]:
264+
def __getitem__(self, index: int) -> Geometry:
272265
"""get geometry at a given index"""
273266
return self.geometries[index]
274267

@@ -290,51 +283,70 @@ def wkt(self) -> str:
290283
z = " Z " if "Z" in geometries else " "
291284
return f"{self.type.upper()}{z}{geometries}"
292285

293-
_validate_bbox = validator("bbox", allow_reuse=True)(validate_bbox)
286+
_validate_bbox = field_validator("bbox")(validate_bbox)
294287

295-
@validator("geometries")
288+
@field_validator("geometries")
296289
def check_geometries(cls, geometries: List) -> List:
297290
"""Add warnings for conditions the spec does not explicitly forbid."""
298291
if len(geometries) == 1:
299292
warnings.warn(
300293
"GeometryCollection should not be used for single geometries."
301294
)
295+
302296
if any(geom.type == "GeometryCollection" for geom in geometries):
303297
warnings.warn(
304298
"GeometryCollection should not be used for nested GeometryCollections."
305299
)
300+
306301
if len({geom.type for geom in geometries}) == 1:
307302
warnings.warn(
308303
"GeometryCollection should not be used for homogeneous collections."
309304
)
305+
310306
return geometries
311307

312308

309+
Geometry = Annotated[
310+
Union[
311+
Point,
312+
MultiPoint,
313+
LineString,
314+
MultiLineString,
315+
Polygon,
316+
MultiPolygon,
317+
GeometryCollection,
318+
],
319+
Field(discriminator="type"),
320+
]
321+
322+
313323
def parse_geometry_obj(obj: Any) -> Geometry:
314324
"""
315325
`obj` is an object that is supposed to represent a GeoJSON geometry. This method returns the
316326
reads the `"type"` field and returns the correct pydantic Geometry model.
317327
"""
318328
if "type" not in obj:
319-
raise ValidationError(
320-
errors=[
321-
ErrorWrapper(ValueError("Missing 'type' field in geometry"), loc="type")
322-
],
323-
model=_GeometryBase,
324-
)
329+
raise ValueError("Missing 'type' field in geometry")
330+
325331
if obj["type"] == "Point":
326-
return Point.parse_obj(obj)
332+
return Point.model_validate(obj)
333+
327334
elif obj["type"] == "MultiPoint":
328-
return MultiPoint.parse_obj(obj)
335+
return MultiPoint.model_validate(obj)
336+
329337
elif obj["type"] == "LineString":
330-
return LineString.parse_obj(obj)
338+
return LineString.model_validate(obj)
339+
331340
elif obj["type"] == "MultiLineString":
332-
return MultiLineString.parse_obj(obj)
341+
return MultiLineString.model_validate(obj)
342+
333343
elif obj["type"] == "Polygon":
334-
return Polygon.parse_obj(obj)
344+
return Polygon.model_validate(obj)
345+
335346
elif obj["type"] == "MultiPolygon":
336-
return MultiPolygon.parse_obj(obj)
337-
raise ValidationError(
338-
errors=[ErrorWrapper(ValueError("Unknown type"), loc="type")],
339-
model=_GeometryBase,
340-
)
347+
return MultiPolygon.model_validate(obj)
348+
349+
elif obj["type"] == "GeometryCollection":
350+
return GeometryCollection.model_validate(obj)
351+
352+
raise ValueError(f"Unknown type: {obj['type']}")

geojson_pydantic/types.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Types for geojson_pydantic models"""
22

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

5-
from pydantic import conlist
5+
from pydantic import Field
6+
from typing_extensions import Annotated
67

78
T = TypeVar("T")
89

@@ -44,13 +45,8 @@ def validate_bbox(bbox: Optional[BBox]) -> Optional[BBox]:
4445
Position = Union[Tuple[float, float], Tuple[float, float, float]]
4546

4647
# Coordinate arrays
47-
if TYPE_CHECKING:
48-
LineStringCoords = List[Position]
49-
LinearRing = List[Position]
50-
else:
51-
LineStringCoords = conlist(Position, min_items=2)
52-
LinearRing = conlist(Position, min_items=4)
53-
48+
LineStringCoords = Annotated[List[Position], Field(min_length=2)]
49+
LinearRing = Annotated[List[Position], Field(min_length=4)]
5450
MultiPointCoords = List[Position]
5551
MultiLineStringCoords = List[LineStringCoords]
5652
PolygonCoords = List[LinearRing]

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ classifiers = [
2020
"Typing :: Typed",
2121
]
2222
dynamic = ["version"]
23-
dependencies = ["pydantic~=1.0"]
23+
dependencies = ["pydantic~=2.0"]
2424

2525
[project.optional-dependencies]
2626
test = ["pytest", "pytest-cov", "shapely"]

tests/test_features.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ class Pointy:
174174
__geo_interface__ = {"type": "Point", "coordinates": (0.0, 0.0)}
175175

176176
feat = Feature(type="Feature", geometry=Pointy(), properties={})
177-
assert feat.geometry.dict(exclude_unset=True) == Pointy.__geo_interface__
177+
assert feat.geometry.model_dump(exclude_unset=True) == Pointy.__geo_interface__
178178

179179

180180
def test_feature_with_null_geometry():
@@ -263,3 +263,30 @@ def test_bbox_validation():
263263
bbox=(0, "a", 0, 1, 1, 1),
264264
geometry=None,
265265
)
266+
267+
268+
def test_feature_validation_error_count():
269+
# Tests that validation does not include irrelevant errors to make them
270+
# easier to read. The input below used to raise 18 errors.
271+
# See #93 for more details.
272+
with pytest.raises(ValidationError):
273+
try:
274+
Feature(
275+
type="Feature",
276+
geometry=Polygon(
277+
type="Polygon",
278+
coordinates=[
279+
[
280+
(-55.9947406591177, -9.26104045526505),
281+
(-55.9976752102375, -9.266589696568962),
282+
(-56.00200328975916, -9.264041751931352),
283+
(-55.99899921566248, -9.257935213034594),
284+
(-55.99477406591177, -9.26103945526505),
285+
]
286+
],
287+
),
288+
properties={},
289+
)
290+
except ValidationError as e:
291+
assert e.error_count() == 1
292+
raise

0 commit comments

Comments
 (0)