Skip to content

Commit aca60cf

Browse files
committed
Typehint all __geo_interfaces__ (mostly, but not entirely GeoJSON compliant)
1 parent 2c66018 commit aca60cf

File tree

2 files changed

+44
-35
lines changed

2 files changed

+44
-35
lines changed

.github/workflows/run_checks_build_and_test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
python -m pip install --upgrade pip
2727
pip install pytest pylint pylint-per-file-ignores
2828
pip install -e .
29-
- name: run Pylint for errors and warnings only
29+
- name: run Pylint for errors, warnings and remarks only (ignore Comments/ Code style)
3030
run: |
3131
pylint --disable=C test_shapefile.py src/shapefile.py
3232

src/shapefile.py

Lines changed: 43 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -145,72 +145,87 @@ class HasGeoInterface(Protocol):
145145
@property
146146
def __geo_interface__(self) -> Any: ...
147147

148+
148149
class GeoJSONPoint(TypedDict):
149150
type: Literal["Point"]
150-
# We fix to a tuple (to statically check the length is 2, 3 or 4) but
151+
# We fix to a tuple (to statically check the length is 2, 3 or 4) but
151152
# RFC7946 only requires: "A position is an array of numbers. There MUST be two or more
152153
# elements. "
153154
# RFC7946 also requires long/lat easting/northing which we do not enforce,
154155
# and despite the SHOULD NOT, we may use a 4th element for Shapefile M Measures.
155-
coordinates: Union[Point, tuple[()]]
156-
156+
coordinates: Union[Point, tuple[()]]
157+
158+
157159
class GeoJSONMultiPoint(TypedDict):
158160
type: Literal["MultiPoint"]
159161
coordinates: Points
160162

163+
161164
class GeoJSONLineString(TypedDict):
162165
type: Literal["LineString"]
163166
# "Two or more positions" not enforced by type checker
164167
# https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.4
165168
coordinates: Points
166-
169+
170+
167171
class GeoJSONMultiLineString(TypedDict):
168172
type: Literal["MultiLineString"]
169173
coordinates: list[Points]
170174

175+
171176
class GeoJSONPolygon(TypedDict):
172177
type: Literal["Polygon"]
173-
# Other requirements for Polygon not enforced by type checker
178+
# Other requirements for Polygon not enforced by type checker
174179
# https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.6
175180
coordinates: list[Points]
176181

182+
177183
class GeoJSONMultiPolygon(TypedDict):
178184
type: Literal["MultiPolygon"]
179185
coordinates: list[list[Points]]
180186

187+
181188
GeoJSONHomogeneousGeometryObject = Union[
182-
GeoJSONPoint, GeoJSONMultiPoint,
183-
GeoJSONLineString, GeoJSONMultiLineString,
184-
GeoJSONPolygon, GeoJSONMultiPolygon,
189+
GeoJSONPoint,
190+
GeoJSONMultiPoint,
191+
GeoJSONLineString,
192+
GeoJSONMultiLineString,
193+
GeoJSONPolygon,
194+
GeoJSONMultiPolygon,
185195
]
186196

197+
187198
class GeoJSONGeometryCollection(TypedDict):
188199
type: Literal["GeometryCollection"]
189200
geometries: list[GeoJSONHomogeneousGeometryObject]
190201

202+
191203
# RFC7946 3.1
192204
GeoJSONObject = Union[GeoJSONHomogeneousGeometryObject, GeoJSONGeometryCollection]
193205

206+
194207
class GeoJSONFeature(TypedDict):
195208
type: Literal["Feature"]
196-
properties: Optional[dict[str, Any]] # RFC7946 3.2 "(any JSON object or a JSON null value)"
209+
properties: Optional[
210+
dict[str, Any]
211+
] # RFC7946 3.2 "(any JSON object or a JSON null value)"
197212
geometry: Optional[GeoJSONObject]
198213

199214

200215
class GeoJSONFeatureCollection(TypedDict):
201216
type: Literal["FeatureCollection"]
202217
features: list[GeoJSONFeature]
203218

219+
204220
class GeoJSONFeatureCollectionWithBBox(GeoJSONFeatureCollection, total=False):
205221
# bbox is optional
206-
# typing.NotRequired requires Python 3.11
222+
# typing.NotRequired requires Python 3.11
207223
# and we must support 3.9 (at least until October)
208224
# https://docs.python.org/3/library/typing.html#typing.Required
209225
# Is there a backport?
210226
bbox: list[float]
211227

212228

213-
214229
# Helpers
215230

216231
MISSING = [None, ""]
@@ -278,7 +293,7 @@ def __repr__(self):
278293

279294

280295
def signed_area(
281-
coords: Coords,
296+
coords: Points,
282297
fast: bool = False,
283298
) -> float:
284299
"""Return the signed area enclosed by a ring using the linear time
@@ -296,22 +311,22 @@ def signed_area(
296311
return area2 / 2.0
297312

298313

299-
def is_cw(coords: Coords) -> bool:
314+
def is_cw(coords: Points) -> bool:
300315
"""Returns True if a polygon ring has clockwise orientation, determined
301316
by a negatively signed area.
302317
"""
303318
area2 = signed_area(coords, fast=True)
304319
return area2 < 0
305320

306321

307-
def rewind(coords: Reversible[Coord]) -> Coords:
322+
def rewind(coords: Reversible[Point]) -> Points:
308323
"""Returns the input coords in reversed order."""
309324
return list(reversed(coords))
310325

311326

312-
def ring_bbox(coords: Coords) -> BBox:
327+
def ring_bbox(coords: Points) -> BBox:
313328
"""Calculates and returns the bounding box of a ring."""
314-
xs, ys = zip(*coords)
329+
xs, ys = map(list, list(zip(*coords))[:2]) # ignore any z or m values
315330
bbox = min(xs), min(ys), max(xs), max(ys)
316331
return bbox
317332

@@ -332,7 +347,7 @@ def bbox_contains(bbox1: BBox, bbox2: BBox) -> bool:
332347
return contains
333348

334349

335-
def ring_contains_point(coords: Coords, p: Point2D) -> bool:
350+
def ring_contains_point(coords: Points, p: Point2D) -> bool:
336351
"""Fast point-in-polygon crossings algorithm, MacMartin optimization.
337352
338353
Adapted from code by Eric Haynes
@@ -381,7 +396,7 @@ class RingSamplingError(Exception):
381396
pass
382397

383398

384-
def ring_sample(coords: Coords, ccw: bool = False) -> Point2D:
399+
def ring_sample(coords: Points, ccw: bool = False) -> Point2D:
385400
"""Return a sample point guaranteed to be within a ring, by efficiently
386401
finding the first centroid of a coordinate triplet whose orientation
387402
matches the orientation of the ring and passes the point-in-ring test.
@@ -431,14 +446,15 @@ def itercoords():
431446
)
432447

433448

434-
def ring_contains_ring(coords1: Coords, coords2: list[Point2D]) -> bool:
449+
def ring_contains_ring(coords1: Points, coords2: list[Point]) -> bool:
435450
"""Returns True if all vertexes in coords2 are fully inside coords1."""
436-
return all(ring_contains_point(coords1, p2) for p2 in coords2)
451+
# Ignore Z and M values in coords2
452+
return all(ring_contains_point(coords1, p2[:2]) for p2 in coords2)
437453

438454

439455
def organize_polygon_rings(
440-
rings: Iterable[Coords], return_errors: Optional[dict[str, int]] = None
441-
) -> list[list[Coords]]:
456+
rings: Iterable[Points], return_errors: Optional[dict[str, int]] = None
457+
) -> list[list[Points]]:
442458
"""Organize a list of coordinate rings into one or more polygons with holes.
443459
Returns a list of polygons, where each polygon is composed of a single exterior
444460
ring, and one or more interior holes. If a return_errors dict is provided (optional),
@@ -992,8 +1008,8 @@ def __init__(self, shape: Optional[Shape] = None, record: Optional[_Record] = No
9921008
def __geo_interface__(self) -> GeoJSONFeature:
9931009
return {
9941010
"type": "Feature",
995-
"properties": None
996-
if self.record is None
1011+
"properties": None
1012+
if self.record is None
9971013
else self.record.as_dict(date_strings=True),
9981014
"geometry": None
9991015
if self.shape is None or self.shape.shapeType == NULL
@@ -1015,11 +1031,8 @@ def __geo_interface__(self) -> GeoJSONGeometryCollection:
10151031
# Note: currently this will fail if any of the shapes are null-geometries
10161032
# could be fixed by storing the shapefile shapeType upon init, returning geojson type with empty coords
10171033
collection = GeoJSONGeometryCollection(
1018-
type= "GeometryCollection",
1019-
geometries = [shape.__geo_interface__
1020-
for shape in self
1021-
if shape is not None
1022-
],
1034+
type="GeometryCollection",
1035+
geometries=[shape.__geo_interface__ for shape in self if shape is not None],
10231036
)
10241037
return collection
10251038

@@ -1035,10 +1048,6 @@ def __repr__(self):
10351048

10361049
@property
10371050
def __geo_interface__(self) -> GeoJSONFeatureCollection:
1038-
collection = {
1039-
"type": "FeatureCollection",
1040-
"features": [] #shaperec.__geo_interface__ for shaperec in self],
1041-
}
10421051
return GeoJSONFeatureCollection(
10431052
type="FeatureCollection",
10441053
features=[shaperec.__geo_interface__ for shaperec in self],
@@ -1362,7 +1371,7 @@ def __iter__(self):
13621371
def __geo_interface__(self) -> GeoJSONFeatureCollectionWithBBox:
13631372
shaperecords = self.shapeRecords()
13641373
fcollection = GeoJSONFeatureCollectionWithBBox(
1365-
bbox = list(self.bbox),
1374+
bbox=list(self.bbox),
13661375
**shaperecords.__geo_interface__,
13671376
)
13681377
return fcollection

0 commit comments

Comments
 (0)