Skip to content

Commit 4f0bbdf

Browse files
authored
Merge pull request #334 from JamesParrott/pyshp-3.0.0-the-type-hints-strike-back
Pyshp 3.0.0 the type hints strike back
2 parents 5150e32 + aca60cf commit 4f0bbdf

File tree

2 files changed

+131
-44
lines changed

2 files changed

+131
-44
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: 130 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,13 @@
2828
Generic,
2929
Iterable,
3030
Iterator,
31+
Literal,
3132
NoReturn,
3233
Optional,
3334
Protocol,
3435
Reversible,
3536
Sequence,
37+
TypedDict,
3638
TypeVar,
3739
Union,
3840
overload,
@@ -111,7 +113,7 @@
111113
PointM = tuple[float, float, Optional[float]]
112114
PointZ = tuple[float, float, float, Optional[float]]
113115

114-
Coord = Union[Point2D, Point2D, Point3D]
116+
Coord = Union[Point2D, Point3D]
115117
Coords = list[Coord]
116118

117119
Point = Union[Point2D, PointM, PointZ]
@@ -144,6 +146,86 @@ class HasGeoInterface(Protocol):
144146
def __geo_interface__(self) -> Any: ...
145147

146148

149+
class GeoJSONPoint(TypedDict):
150+
type: Literal["Point"]
151+
# We fix to a tuple (to statically check the length is 2, 3 or 4) but
152+
# RFC7946 only requires: "A position is an array of numbers. There MUST be two or more
153+
# elements. "
154+
# RFC7946 also requires long/lat easting/northing which we do not enforce,
155+
# and despite the SHOULD NOT, we may use a 4th element for Shapefile M Measures.
156+
coordinates: Union[Point, tuple[()]]
157+
158+
159+
class GeoJSONMultiPoint(TypedDict):
160+
type: Literal["MultiPoint"]
161+
coordinates: Points
162+
163+
164+
class GeoJSONLineString(TypedDict):
165+
type: Literal["LineString"]
166+
# "Two or more positions" not enforced by type checker
167+
# https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.4
168+
coordinates: Points
169+
170+
171+
class GeoJSONMultiLineString(TypedDict):
172+
type: Literal["MultiLineString"]
173+
coordinates: list[Points]
174+
175+
176+
class GeoJSONPolygon(TypedDict):
177+
type: Literal["Polygon"]
178+
# Other requirements for Polygon not enforced by type checker
179+
# https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.6
180+
coordinates: list[Points]
181+
182+
183+
class GeoJSONMultiPolygon(TypedDict):
184+
type: Literal["MultiPolygon"]
185+
coordinates: list[list[Points]]
186+
187+
188+
GeoJSONHomogeneousGeometryObject = Union[
189+
GeoJSONPoint,
190+
GeoJSONMultiPoint,
191+
GeoJSONLineString,
192+
GeoJSONMultiLineString,
193+
GeoJSONPolygon,
194+
GeoJSONMultiPolygon,
195+
]
196+
197+
198+
class GeoJSONGeometryCollection(TypedDict):
199+
type: Literal["GeometryCollection"]
200+
geometries: list[GeoJSONHomogeneousGeometryObject]
201+
202+
203+
# RFC7946 3.1
204+
GeoJSONObject = Union[GeoJSONHomogeneousGeometryObject, GeoJSONGeometryCollection]
205+
206+
207+
class GeoJSONFeature(TypedDict):
208+
type: Literal["Feature"]
209+
properties: Optional[
210+
dict[str, Any]
211+
] # RFC7946 3.2 "(any JSON object or a JSON null value)"
212+
geometry: Optional[GeoJSONObject]
213+
214+
215+
class GeoJSONFeatureCollection(TypedDict):
216+
type: Literal["FeatureCollection"]
217+
features: list[GeoJSONFeature]
218+
219+
220+
class GeoJSONFeatureCollectionWithBBox(GeoJSONFeatureCollection, total=False):
221+
# bbox is optional
222+
# typing.NotRequired requires Python 3.11
223+
# and we must support 3.9 (at least until October)
224+
# https://docs.python.org/3/library/typing.html#typing.Required
225+
# Is there a backport?
226+
bbox: list[float]
227+
228+
147229
# Helpers
148230

149231
MISSING = [None, ""]
@@ -211,7 +293,7 @@ def __repr__(self):
211293

212294

213295
def signed_area(
214-
coords: Coords,
296+
coords: Points,
215297
fast: bool = False,
216298
) -> float:
217299
"""Return the signed area enclosed by a ring using the linear time
@@ -229,22 +311,22 @@ def signed_area(
229311
return area2 / 2.0
230312

231313

232-
def is_cw(coords: Coords) -> bool:
314+
def is_cw(coords: Points) -> bool:
233315
"""Returns True if a polygon ring has clockwise orientation, determined
234316
by a negatively signed area.
235317
"""
236318
area2 = signed_area(coords, fast=True)
237319
return area2 < 0
238320

239321

240-
def rewind(coords: Reversible[Coord]) -> Coords:
322+
def rewind(coords: Reversible[Point]) -> Points:
241323
"""Returns the input coords in reversed order."""
242324
return list(reversed(coords))
243325

244326

245-
def ring_bbox(coords: Coords) -> BBox:
327+
def ring_bbox(coords: Points) -> BBox:
246328
"""Calculates and returns the bounding box of a ring."""
247-
xs, ys = zip(*coords)
329+
xs, ys = map(list, list(zip(*coords))[:2]) # ignore any z or m values
248330
bbox = min(xs), min(ys), max(xs), max(ys)
249331
return bbox
250332

@@ -265,7 +347,7 @@ def bbox_contains(bbox1: BBox, bbox2: BBox) -> bool:
265347
return contains
266348

267349

268-
def ring_contains_point(coords: Coords, p: Point2D) -> bool:
350+
def ring_contains_point(coords: Points, p: Point2D) -> bool:
269351
"""Fast point-in-polygon crossings algorithm, MacMartin optimization.
270352
271353
Adapted from code by Eric Haynes
@@ -314,7 +396,7 @@ class RingSamplingError(Exception):
314396
pass
315397

316398

317-
def ring_sample(coords: Coords, ccw: bool = False) -> Point2D:
399+
def ring_sample(coords: Points, ccw: bool = False) -> Point2D:
318400
"""Return a sample point guaranteed to be within a ring, by efficiently
319401
finding the first centroid of a coordinate triplet whose orientation
320402
matches the orientation of the ring and passes the point-in-ring test.
@@ -364,14 +446,15 @@ def itercoords():
364446
)
365447

366448

367-
def ring_contains_ring(coords1: Coords, coords2: list[Point2D]) -> bool:
449+
def ring_contains_ring(coords1: Points, coords2: list[Point]) -> bool:
368450
"""Returns True if all vertexes in coords2 are fully inside coords1."""
369-
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)
370453

371454

372455
def organize_polygon_rings(
373-
rings: Iterable[Coords], return_errors: Optional[dict[str, int]] = None
374-
) -> list[list[Coords]]:
456+
rings: Iterable[Points], return_errors: Optional[dict[str, int]] = None
457+
) -> list[list[Points]]:
375458
"""Organize a list of coordinate rings into one or more polygons with holes.
376459
Returns a list of polygons, where each polygon is composed of a single exterior
377460
ring, and one or more interior holes. If a return_errors dict is provided (optional),
@@ -541,7 +624,7 @@ def __init__(
541624
# self.bbox: Optional[_Array[float]] = None
542625

543626
@property
544-
def __geo_interface__(self):
627+
def __geo_interface__(self) -> GeoJSONHomogeneousGeometryObject:
545628
if self.shapeType in [POINT, POINTM, POINTZ]:
546629
# point
547630
if len(self.points) == 0:
@@ -922,17 +1005,19 @@ def __init__(self, shape: Optional[Shape] = None, record: Optional[_Record] = No
9221005
self.record = record
9231006

9241007
@property
925-
def __geo_interface__(self):
1008+
def __geo_interface__(self) -> GeoJSONFeature:
9261009
return {
9271010
"type": "Feature",
928-
"properties": self.record.as_dict(date_strings=True),
1011+
"properties": None
1012+
if self.record is None
1013+
else self.record.as_dict(date_strings=True),
9291014
"geometry": None
930-
if self.shape.shapeType == NULL
1015+
if self.shape is None or self.shape.shapeType == NULL
9311016
else self.shape.__geo_interface__,
9321017
}
9331018

9341019

935-
class Shapes(list):
1020+
class Shapes(list[Optional[Shape]]):
9361021
"""A class to hold a list of Shape objects. Subclasses list to ensure compatibility with
9371022
former work and to reuse all the optimizations of the builtin list.
9381023
In addition to the list interface, this also provides the GeoJSON __geo_interface__
@@ -942,17 +1027,17 @@ def __repr__(self):
9421027
return f"Shapes: {list(self)}"
9431028

9441029
@property
945-
def __geo_interface__(self):
1030+
def __geo_interface__(self) -> GeoJSONGeometryCollection:
9461031
# Note: currently this will fail if any of the shapes are null-geometries
9471032
# could be fixed by storing the shapefile shapeType upon init, returning geojson type with empty coords
948-
collection = {
949-
"type": "GeometryCollection",
950-
"geometries": [shape.__geo_interface__ for shape in self],
951-
}
1033+
collection = GeoJSONGeometryCollection(
1034+
type="GeometryCollection",
1035+
geometries=[shape.__geo_interface__ for shape in self if shape is not None],
1036+
)
9521037
return collection
9531038

9541039

955-
class ShapeRecords(list):
1040+
class ShapeRecords(list[ShapeRecord]):
9561041
"""A class to hold a list of ShapeRecord objects. Subclasses list to ensure compatibility with
9571042
former work and to reuse all the optimizations of the builtin list.
9581043
In addition to the list interface, this also provides the GeoJSON __geo_interface__
@@ -962,12 +1047,11 @@ def __repr__(self):
9621047
return f"ShapeRecords: {list(self)}"
9631048

9641049
@property
965-
def __geo_interface__(self):
966-
collection = {
967-
"type": "FeatureCollection",
968-
"features": [shaperec.__geo_interface__ for shaperec in self],
969-
}
970-
return collection
1050+
def __geo_interface__(self) -> GeoJSONFeatureCollection:
1051+
return GeoJSONFeatureCollection(
1052+
type="FeatureCollection",
1053+
features=[shaperec.__geo_interface__ for shaperec in self],
1054+
)
9711055

9721056

9731057
class ShapefileException(Exception):
@@ -1284,10 +1368,12 @@ def __iter__(self):
12841368
yield from self.iterShapeRecords()
12851369

12861370
@property
1287-
def __geo_interface__(self):
1371+
def __geo_interface__(self) -> GeoJSONFeatureCollectionWithBBox:
12881372
shaperecords = self.shapeRecords()
1289-
fcollection = shaperecords.__geo_interface__
1290-
fcollection["bbox"] = list(self.bbox)
1373+
fcollection = GeoJSONFeatureCollectionWithBBox(
1374+
bbox=list(self.bbox),
1375+
**shaperecords.__geo_interface__,
1376+
)
12911377
return fcollection
12921378

12931379
@property
@@ -2793,22 +2879,22 @@ def pointz(self, x: float, y: float, z: float = 0.0, m: Optional[float] = None):
27932879
pointShape.points.append((x, y, z, m))
27942880
self.shape(pointShape)
27952881

2796-
def multipoint(self, points: Coords):
2882+
def multipoint(self, points: Points):
27972883
"""Creates a MULTIPOINT shape.
27982884
Points is a list of xy values."""
27992885
shapeType = MULTIPOINT
28002886
# nest the points inside a list to be compatible with the generic shapeparts method
28012887
self._shapeparts(parts=[points], shapeType=shapeType)
28022888

2803-
def multipointm(self, points: list[PointM]):
2889+
def multipointm(self, points: Points):
28042890
"""Creates a MULTIPOINTM shape.
28052891
Points is a list of xym values.
28062892
If the m (measure) value is not included, it defaults to None (NoData)."""
28072893
shapeType = MULTIPOINTM
28082894
# nest the points inside a list to be compatible with the generic shapeparts method
28092895
self._shapeparts(parts=[points], shapeType=shapeType)
28102896

2811-
def multipointz(self, points):
2897+
def multipointz(self, points: Points):
28122898
"""Creates a MULTIPOINTZ shape.
28132899
Points is a list of xyzm values.
28142900
If the z (elevation) value is not included, it defaults to 0.
@@ -2817,7 +2903,7 @@ def multipointz(self, points):
28172903
# nest the points inside a list to be compatible with the generic shapeparts method
28182904
self._shapeparts(parts=[points], shapeType=shapeType)
28192905

2820-
def line(self, lines: list[Coords]):
2906+
def line(self, lines: list[Points]):
28212907
"""Creates a POLYLINE shape.
28222908
Lines is a collection of lines, each made up of a list of xy values."""
28232909
shapeType = POLYLINE
@@ -2838,7 +2924,7 @@ def linez(self, lines: list[Points]):
28382924
shapeType = POLYLINEZ
28392925
self._shapeparts(parts=lines, shapeType=shapeType)
28402926

2841-
def poly(self, polys: list[Coords]):
2927+
def poly(self, polys: list[Points]):
28422928
"""Creates a POLYGON shape.
28432929
Polys is a collection of polygons, each made up of a list of xy values.
28442930
Note that for ordinary polygons the coordinates must run in a clockwise direction.
@@ -2865,7 +2951,7 @@ def polyz(self, polys: list[Points]):
28652951
shapeType = POLYGONZ
28662952
self._shapeparts(parts=polys, shapeType=shapeType)
28672953

2868-
def multipatch(self, parts: list[list[PointZ]], partTypes: list[int]):
2954+
def multipatch(self, parts: list[Points], partTypes: list[int]):
28692955
"""Creates a MULTIPATCH shape.
28702956
Parts is a collection of 3D surface patches, each made up of a list of xyzm values.
28712957
PartTypes is a list of types that define each of the surface patches.
@@ -2891,7 +2977,7 @@ def multipatch(self, parts: list[list[PointZ]], partTypes: list[int]):
28912977
# write the shape
28922978
self.shape(polyShape)
28932979

2894-
def _shapeparts(self, parts, shapeType):
2980+
def _shapeparts(self, parts: list[Points], shapeType: int):
28952981
"""Internal method for adding a shape that has multiple collections of points (parts):
28962982
lines, polygons, and multipoint shapes.
28972983
"""
@@ -2908,10 +2994,11 @@ def _shapeparts(self, parts, shapeType):
29082994
# set part index position
29092995
polyShape.parts.append(len(polyShape.points))
29102996
# add points
2911-
for point in part:
2912-
# Ensure point is list
2913-
point_list = list(point)
2914-
polyShape.points.append(point_list)
2997+
# for point in part:
2998+
# # Ensure point is list
2999+
# point_list = list(point)
3000+
# polyShape.points.append(point_list)
3001+
polyShape.points.extend(part)
29153002
# write the shape
29163003
self.shape(polyShape)
29173004

0 commit comments

Comments
 (0)