Skip to content

Commit 4e5e0f0

Browse files
Merge pull request #107 from eseglem/bugfix/102-wkt-mixed-dimensionality
Address mixed dimensionality in wkt
2 parents 46c088d + 8a2e2c6 commit 4e5e0f0

File tree

2 files changed

+104
-62
lines changed

2 files changed

+104
-62
lines changed

geojson_pydantic/geometries.py

Lines changed: 63 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
"""pydantic models for GeoJSON Geometry objects."""
2-
32
import abc
4-
from typing import Any, Iterator, List, Literal, Optional, Union
3+
from typing import Any, Iterator, List, Literal, Optional, Protocol, Union
54

65
from pydantic import BaseModel, Field, ValidationError, validator
76
from pydantic.error_wrappers import ErrorWrapper
@@ -20,70 +19,87 @@
2019
)
2120

2221

23-
def _position_wkt_coordinates(position: Position) -> str:
22+
def _position_wkt_coordinates(coordinates: Position, force_z: bool = False) -> str:
2423
"""Converts a Position to WKT Coordinates."""
25-
return " ".join(str(number) for number in position)
24+
wkt_coordinates = " ".join(str(number) for number in coordinates)
25+
if force_z and len(coordinates) < 3:
26+
wkt_coordinates += " 0.0"
27+
return wkt_coordinates
2628

2729

2830
def _position_has_z(position: Position) -> bool:
2931
return len(position) == 3
3032

3133

32-
def _position_list_wkt_coordinates(positions: List[Position]) -> str:
34+
def _position_list_wkt_coordinates(
35+
coordinates: List[Position], force_z: bool = False
36+
) -> str:
3337
"""Converts a list of Positions to WKT Coordinates."""
34-
return ", ".join(_position_wkt_coordinates(position) for position in positions)
38+
return ", ".join(
39+
_position_wkt_coordinates(position, force_z) for position in coordinates
40+
)
3541

3642

3743
def _position_list_has_z(positions: List[Position]) -> bool:
3844
"""Checks if any position in a list has a Z."""
3945
return any(_position_has_z(position) for position in positions)
4046

4147

42-
def _lines_wtk_coordinates(lines: List[List[Position]]) -> str:
48+
def _lines_wtk_coordinates(
49+
coordinates: List[LineStringCoords], force_z: bool = False
50+
) -> str:
4351
"""Converts lines to WKT Coordinates."""
44-
return ", ".join(f"({_position_list_wkt_coordinates(line)})" for line in lines)
52+
return ", ".join(
53+
f"({_position_list_wkt_coordinates(line, force_z)})" for line in coordinates
54+
)
4555

4656

47-
def _lines_has_z(lines: List[List[Position]]) -> bool:
57+
def _lines_has_z(lines: List[LineStringCoords]) -> bool:
4858
"""Checks if any position in a list has a Z."""
4959
return any(
5060
_position_has_z(position) for positions in lines for position in positions
5161
)
5262

5363

64+
def _polygons_wkt_coordinates(
65+
coordinates: List[PolygonCoords], force_z: bool = False
66+
) -> str:
67+
return ",".join(
68+
f"({_lines_wtk_coordinates(polygon, force_z)})" for polygon in coordinates
69+
)
70+
71+
72+
class _WktCallable(Protocol):
73+
def __call__(self, coordinates: Any, force_z: bool) -> str:
74+
...
75+
76+
5477
class _GeometryBase(BaseModel, abc.ABC, GeoInterfaceMixin):
5578
"""Base class for geometry models"""
5679

5780
type: str
5881
coordinates: Any
5982
bbox: Optional[BBox] = None
6083

84+
__wkt_coordinates__: _WktCallable
85+
6186
@property
6287
@abc.abstractmethod
6388
def has_z(self) -> bool:
6489
"""Checks if any coordinate has a Z value."""
6590
...
6691

67-
@property
68-
@abc.abstractmethod
69-
def _wkt_coordinates(self) -> str:
70-
...
71-
72-
@property
73-
def _wkt_type(self) -> str:
74-
"""Return the WKT name of the geometry."""
75-
return self.type.upper()
76-
7792
@property
7893
def wkt(self) -> str:
7994
"""Return the Well Known Text representation."""
8095
# Start with the WKT Type
81-
wkt = self._wkt_type
96+
wkt = self.type.upper()
97+
has_z = self.has_z
8298
if self.coordinates:
8399
# If any of the coordinates have a Z add a "Z" to the WKT
84-
wkt += " Z " if self.has_z else " "
100+
wkt += " Z " if has_z else " "
85101
# Add the rest of the WKT inside parentheses
86-
wkt += f"({self._wkt_coordinates})"
102+
wkt += f"({self.__wkt_coordinates__(self.coordinates, force_z=has_z)})"
87103
else:
88104
# Otherwise it will be "EMPTY"
89105
wkt += " EMPTY"
@@ -97,70 +113,64 @@ class Point(_GeometryBase):
97113
type: Literal["Point"]
98114
coordinates: Position
99115

116+
__wkt_coordinates__ = staticmethod(_position_wkt_coordinates)
117+
100118
@property
101119
def has_z(self) -> bool:
102120
"""Checks if any coordinate has a Z value."""
103121
return _position_has_z(self.coordinates)
104122

105-
@property
106-
def _wkt_coordinates(self) -> str:
107-
return _position_wkt_coordinates(self.coordinates)
108-
109123

110124
class MultiPoint(_GeometryBase):
111125
"""MultiPoint Model"""
112126

113127
type: Literal["MultiPoint"]
114128
coordinates: MultiPointCoords
115129

130+
__wkt_coordinates__ = staticmethod(_position_list_wkt_coordinates)
131+
116132
@property
117133
def has_z(self) -> bool:
118134
"""Checks if any coordinate has a Z value."""
119135
return _position_list_has_z(self.coordinates)
120136

121-
@property
122-
def _wkt_coordinates(self) -> str:
123-
return _position_list_wkt_coordinates(self.coordinates)
124-
125137

126138
class LineString(_GeometryBase):
127139
"""LineString Model"""
128140

129141
type: Literal["LineString"]
130142
coordinates: LineStringCoords
131143

144+
__wkt_coordinates__ = staticmethod(_position_list_wkt_coordinates)
145+
132146
@property
133147
def has_z(self) -> bool:
134148
"""Checks if any coordinate has a Z value."""
135149
return _position_list_has_z(self.coordinates)
136150

137-
@property
138-
def _wkt_coordinates(self) -> str:
139-
return _position_list_wkt_coordinates(self.coordinates)
140-
141151

142152
class MultiLineString(_GeometryBase):
143153
"""MultiLineString Model"""
144154

145155
type: Literal["MultiLineString"]
146156
coordinates: MultiLineStringCoords
147157

158+
__wkt_coordinates__ = staticmethod(_lines_wtk_coordinates)
159+
148160
@property
149161
def has_z(self) -> bool:
150162
"""Checks if any coordinate has a Z value."""
151163
return _lines_has_z(self.coordinates)
152164

153-
@property
154-
def _wkt_coordinates(self) -> str:
155-
return _lines_wtk_coordinates(self.coordinates)
156-
157165

158166
class Polygon(_GeometryBase):
159167
"""Polygon Model"""
160168

161169
type: Literal["Polygon"]
162170
coordinates: PolygonCoords
163171

172+
__wkt_coordinates__ = staticmethod(_lines_wtk_coordinates)
173+
164174
@validator("coordinates")
165175
def check_closure(cls, coordinates: List) -> List:
166176
"""Validate that Polygon is closed (first and last coordinate are the same)."""
@@ -186,10 +196,6 @@ def has_z(self) -> bool:
186196
"""Checks if any coordinates have a Z value."""
187197
return _lines_has_z(self.coordinates)
188198

189-
@property
190-
def _wkt_coordinates(self) -> str:
191-
return _lines_wtk_coordinates(self.coordinates)
192-
193199
@classmethod
194200
def from_bounds(
195201
cls, xmin: float, ymin: float, xmax: float, ymax: float
@@ -209,17 +215,13 @@ class MultiPolygon(_GeometryBase):
209215
type: Literal["MultiPolygon"]
210216
coordinates: MultiPolygonCoords
211217

218+
__wkt_coordinates__ = staticmethod(_polygons_wkt_coordinates)
219+
212220
@property
213221
def has_z(self) -> bool:
214222
"""Checks if any coordinates have a Z value."""
215223
return any(_lines_has_z(polygon) for polygon in self.coordinates)
216224

217-
@property
218-
def _wkt_coordinates(self) -> str:
219-
return ",".join(
220-
f"({_lines_wtk_coordinates(polygon)})" for polygon in self.coordinates
221-
)
222-
223225
@validator("coordinates")
224226
def check_closure(cls, coordinates: List) -> List:
225227
"""Validate that Polygon is closed (first and last coordinate are the same)."""
@@ -254,24 +256,23 @@ def __getitem__(self, index: int) -> Geometry:
254256
"""get geometry at a given index"""
255257
return self.geometries[index]
256258

257-
@property
258-
def _wkt_type(self) -> str:
259-
"""Return the WKT name of the geometry."""
260-
return self.type.upper()
261-
262-
@property
263-
def _wkt_coordinates(self) -> str:
264-
"""Encode coordinates as WKT."""
265-
return ", ".join(geom.wkt for geom in self.geometries)
266-
267259
@property
268260
def wkt(self) -> str:
269261
"""Return the Well Known Text representation."""
270-
return (
271-
self._wkt_type
272-
+ " "
273-
+ (f"({self._wkt_coordinates})" if self._wkt_coordinates else "EMPTY")
262+
# Each geometry will check its own coordinates for Z and include "Z" in the wkt
263+
# if necessary. Rather than looking at the coordinates for each of the geometries
264+
# again, we can just get the wkt from each of them and check if there is a Z
265+
# anywhere in the text.
266+
267+
# Get the wkt from each of the geometries in the collection
268+
geometries = (
269+
f'({", ".join(geom.wkt for geom in self.geometries)})'
270+
if self.geometries
271+
else "EMPTY"
274272
)
273+
# If any of them contain `Z` add Z to the output wkt
274+
z = " Z " if "Z" in geometries else " "
275+
return f"{self.type.upper()}{z}{geometries}"
275276

276277

277278
def parse_geometry_obj(obj: Any) -> Geometry:

tests/test_geometries.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ def test_point_invalid_coordinates(coordinates):
5959
[(1.0, 2.0), (1.0, 2.0)],
6060
# Has Z
6161
[(1.0, 2.0, 3.0), (1.0, 2.0, 3.0)],
62+
# Mixed
63+
[(1.0, 2.0), (1.0, 2.0, 3.0)],
6264
],
6365
)
6466
def test_multi_point_valid_coordinates(coordinates):
@@ -93,6 +95,7 @@ def test_multi_point_invalid_coordinates(coordinates):
9395
[(1.0, 2.0), (3.0, 4.0), (5.0, 6.0)],
9496
# Two Points, has Z
9597
[(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)],
98+
# Shapely doesn't like mixed here
9699
],
97100
)
98101
def test_line_string_valid_coordinates(coordinates):
@@ -130,6 +133,8 @@ def test_line_string_invalid_coordinates(coordinates):
130133
[[(1.0, 2.0), (3.0, 4.0)], [(0.0, 0.0), (1.0, 1.0)]],
131134
# Two lines, two points each, has Z
132135
[[(1.0, 2.0, 0.0), (3.0, 4.0, 1.0)], [(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)]],
136+
# Mixed
137+
[[(1.0, 2.0), (3.0, 4.0)], [(0.0, 0.0, 0.0), (1.0, 1.0, 1.0)]],
133138
],
134139
)
135140
def test_multi_line_string_valid_coordinates(coordinates):
@@ -206,6 +211,17 @@ def test_polygon_valid_coordinates(coordinates):
206211
(2.0, 2.0, 1.0),
207212
],
208213
],
214+
# Mixed
215+
[
216+
[(0.0, 0.0), (0.0, 10.0), (10.0, 10.0), (10.0, 0.0), (0.0, 0.0)],
217+
[
218+
(2.0, 2.0, 2.0),
219+
(2.0, 4.0, 0.0),
220+
(4.0, 4.0, 0.0),
221+
(4.0, 2.0, 0.0),
222+
(2.0, 2.0, 2.0),
223+
],
224+
],
209225
],
210226
)
211227
def test_polygon_with_holes(coordinates):
@@ -271,6 +287,19 @@ def test_polygon_invalid_coordinates(coordinates):
271287
],
272288
]
273289
],
290+
# Mixed
291+
[
292+
[
293+
[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.0, 0.0)],
294+
[
295+
(2.1, 2.1, 2.1),
296+
(2.2, 2.1, 2.0),
297+
(2.2, 2.2, 2.2),
298+
(2.1, 2.2, 2.3),
299+
(2.1, 2.1, 2.1),
300+
],
301+
]
302+
],
274303
],
275304
)
276305
def test_multi_polygon(coordinates):
@@ -452,6 +481,18 @@ def test_getitem_geometry_collection(polygon):
452481
assert item == gc[0]
453482

454483

484+
def test_wkt_mixed_geometry_collection():
485+
point = Point(type="Point", coordinates=(0.0, 0.0, 0.0))
486+
line_string = LineString(type="LineString", coordinates=[(0.0, 0.0), (1.0, 1.0)])
487+
gc = GeometryCollection(type="GeometryCollection", geometries=[point, line_string])
488+
assert_wkt_equivalence(gc)
489+
490+
491+
def test_wkt_empty_geometry_collection():
492+
gc = GeometryCollection(type="GeometryCollection", geometries=[])
493+
assert_wkt_equivalence(gc)
494+
495+
455496
def test_polygon_from_bounds():
456497
"""Result from `from_bounds` class method should be the same."""
457498
coordinates = [[(1.0, 2.0), (3.0, 2.0), (3.0, 4.0), (1.0, 4.0), (1.0, 2.0)]]

0 commit comments

Comments
 (0)