From 41366bb63bc172eeaabbd21f143087e326343f87 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Sat, 26 Jul 2025 22:51:23 +0100 Subject: [PATCH 01/10] Type hint Writer.field. Correct type of FieldTuple --- src/shapefile.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/shapefile.py b/src/shapefile.py index 41f6359..3bf86a4 100644 --- a/src/shapefile.py +++ b/src/shapefile.py @@ -131,7 +131,7 @@ def tell(self): ... BinaryFileT = Union[str, IO[bytes]] BinaryFileStreamT = Union[IO[bytes], io.BytesIO, BinaryWritableSeekable] -FieldTuple = tuple[str, str, int, bool] +FieldTuple = tuple[str, str, int, int] RecordValue = Union[ bool, int, float, str, date ] # A Possible value in a Shapefile record, e.g. L, N, F, C, D types @@ -2924,13 +2924,20 @@ def _shapeparts(self, parts, shapeType): # write the shape self.shape(polyShape) - def field(self, name, fieldType="C", size="50", decimal=0): + def field( + # Types of args should match *FieldTuple + self, + name: str, + fieldType: str = "C", + size: int = 50, + decimal: int = 0, + ): """Adds a dbf field descriptor to the shapefile.""" if fieldType == "D": - size = "8" + size = 8 decimal = 0 elif fieldType == "L": - size = "1" + size = 1 decimal = 0 if len(self.fields) >= 2046: raise ShapefileException( From 0f3c56f20585c68b7cb99dbe63dafe3db5d3a4e1 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Sat, 26 Jul 2025 22:54:59 +0100 Subject: [PATCH 02/10] Type hint Writer.line & Writer.poly --- src/shapefile.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shapefile.py b/src/shapefile.py index 3bf86a4..5d00458 100644 --- a/src/shapefile.py +++ b/src/shapefile.py @@ -2827,7 +2827,7 @@ def multipointz(self, points): ] # nest the points inside a list to be compatible with the generic shapeparts method self._shapeparts(parts=points, shapeType=shapeType) - def line(self, lines): + def line(self, lines: Collection[Coords]): """Creates a POLYLINE shape. Lines is a collection of lines, each made up of a list of xy values.""" shapeType = POLYLINE @@ -2848,7 +2848,7 @@ def linez(self, lines): shapeType = POLYLINEZ self._shapeparts(parts=lines, shapeType=shapeType) - def poly(self, polys): + def poly(self, polys: Collection[Coords]): """Creates a POLYGON shape. Polys is a collection of polygons, each made up of a list of xy values. Note that for ordinary polygons the coordinates must run in a clockwise direction. From 3edbc72bfcf7e0aafab1ee3e472dc01bc614c1c0 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Sun, 27 Jul 2025 15:15:56 +0100 Subject: [PATCH 03/10] list[Coord] -> Coords --- src/shapefile.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/shapefile.py b/src/shapefile.py index 5d00458..3762ff0 100644 --- a/src/shapefile.py +++ b/src/shapefile.py @@ -242,7 +242,7 @@ def is_cw(coords: Coords) -> bool: return area2 < 0 -def rewind(coords: Reversible[Coord]) -> list[Coord]: +def rewind(coords: Reversible[Coord]) -> Coords: """Returns the input coords in reversed order.""" return list(reversed(coords)) @@ -270,7 +270,7 @@ def bbox_contains(bbox1: BBox, bbox2: BBox) -> bool: return contains -def ring_contains_point(coords: list[Coord], p: Point2D) -> bool: +def ring_contains_point(coords: Coords, p: Point2D) -> bool: """Fast point-in-polygon crossings algorithm, MacMartin optimization. Adapted from code by Eric Haynes @@ -319,7 +319,7 @@ class RingSamplingError(Exception): pass -def ring_sample(coords: list[Coord], ccw: bool = False) -> Point2D: +def ring_sample(coords: Coords, ccw: bool = False) -> Point2D: """Return a sample point guaranteed to be within a ring, by efficiently finding the first centroid of a coordinate triplet whose orientation matches the orientation of the ring and passes the point-in-ring test. @@ -369,14 +369,14 @@ def itercoords(): ) -def ring_contains_ring(coords1: list[Coord], coords2: list[Point2D]) -> bool: +def ring_contains_ring(coords1: Coords, coords2: list[Point2D]) -> bool: """Returns True if all vertexes in coords2 are fully inside coords1.""" return all(ring_contains_point(coords1, p2) for p2 in coords2) def organize_polygon_rings( - rings: Iterable[list[Coord]], return_errors: Optional[dict[str, int]] = None -) -> list[list[list[Coord]]]: + rings: Iterable[Coords], return_errors: Optional[dict[str, int]] = None +) -> list[list[Coords]]: """Organize a list of coordinate rings into one or more polygons with holes. Returns a list of polygons, where each polygon is composed of a single exterior ring, and one or more interior holes. If a return_errors dict is provided (optional), @@ -510,7 +510,7 @@ class Shape: def __init__( self, shapeType: int = NULL, - points: Optional[list[Coord]] = None, + points: Optional[Coords] = None, parts: Optional[Sequence[int]] = None, partTypes: Optional[Sequence[int]] = None, oid: Optional[int] = None, From bde8771a55dcdc2be2006db98133bb074967a4c4 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Sun, 27 Jul 2025 15:58:59 +0100 Subject: [PATCH 04/10] Distinguish between PointM and Point3D, and allow PointZ[3] to be None --- src/shapefile.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/shapefile.py b/src/shapefile.py index 3762ff0..a1e423d 100644 --- a/src/shapefile.py +++ b/src/shapefile.py @@ -109,10 +109,11 @@ T = TypeVar("T") Point2D = tuple[float, float] -PointZ = tuple[float, float, float] -PointZM = tuple[float, float, float, float] +Point3D = tuple[float, float, float] +PointM = tuple[float, float, Optional[float]] +PointZ = tuple[float, float, float, Optional[float]] -Coord = Union[Point2D, PointZ, PointZM] +Coord = Union[Point2D, Point2D, Point3D] Coords = list[Coord] BBox = tuple[float, float, float, float] @@ -140,7 +141,7 @@ def tell(self): ... class GeoJsonShapeT(TypedDict): type: str coordinates: Union[ - tuple[()], Point2D, PointZ, PointZM, Coords, list[Coords], list[list[Coords]] + tuple[()], Point2D, PointM, PointZ, Coords, list[Coords], list[list[Coords]] ] From 928ed2d27755f4ece1fa944288c93935bea5370c Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Sun, 27 Jul 2025 16:56:44 +0100 Subject: [PATCH 05/10] Relax GeoJsonShapeT for now --- src/shapefile.py | 37 +++++++++++++++++-------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/shapefile.py b/src/shapefile.py index a1e423d..abee5dc 100644 --- a/src/shapefile.py +++ b/src/shapefile.py @@ -116,6 +116,9 @@ Coord = Union[Point2D, Point2D, Point3D] Coords = list[Coord] +Point = Union[Point2D, PointM, PointZ] +Points = list[Point] + BBox = tuple[float, float, float, float] @@ -138,11 +141,6 @@ def tell(self): ... ] # A Possible value in a Shapefile record, e.g. L, N, F, C, D types -class GeoJsonShapeT(TypedDict): - type: str - coordinates: Union[ - tuple[()], Point2D, PointM, PointZ, Coords, list[Coords], list[list[Coords]] - ] class HasGeoInterface(Protocol): @@ -255,11 +253,11 @@ def ring_bbox(coords: Coords) -> BBox: return bbox -def bbox_overlap(bbox1: BBox, bbox2: Collection[float]) -> bool: +def bbox_overlap(bbox1: BBox, bbox2: BBox) -> bool: """Tests whether two bounding boxes overlap.""" xmin1, ymin1, xmax1, ymax1 = bbox1 xmin2, ymin2, xmax2, ymax2 = bbox2 - overlap = xmin1 <= xmax2 and xmax1 >= xmin2 and ymin1 <= ymax2 and ymax1 >= ymin2 + overlap = xmin1 <= xmax2 and xmin2 <= xmax1 and ymin1 <= ymax2 and ymin2 <= ymax1 return overlap @@ -267,7 +265,7 @@ def bbox_contains(bbox1: BBox, bbox2: BBox) -> bool: """Tests whether bbox1 fully contains bbox2.""" xmin1, ymin1, xmax1, ymax1 = bbox1 xmin2, ymin2, xmax2, ymax2 = bbox2 - contains = xmin1 < xmin2 and xmax1 > xmax2 and ymin1 < ymin2 and ymax1 > ymax2 + contains = xmin1 < xmin2 and xmax2 < xmax1 and ymin1 < ymin2 and ymax2 < ymax1 return contains @@ -511,7 +509,7 @@ class Shape: def __init__( self, shapeType: int = NULL, - points: Optional[Coords] = None, + points: Optional[Points] = None, parts: Optional[Sequence[int]] = None, partTypes: Optional[Sequence[int]] = None, oid: Optional[int] = None, @@ -547,7 +545,7 @@ def __init__( # self.bbox: Optional[_Array[float]] = None @property - def __geo_interface__(self) -> GeoJsonShapeT: + def __geo_interface__(self): if self.shapeType in [POINT, POINTM, POINTZ]: # point if len(self.points) == 0: @@ -1435,7 +1433,7 @@ def __shpHeader(self): shp.seek(32) self.shapeType = unpack(" Date: Sun, 27 Jul 2025 16:57:30 +0100 Subject: [PATCH 06/10] Reformat --- src/shapefile.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/shapefile.py b/src/shapefile.py index abee5dc..2152ee7 100644 --- a/src/shapefile.py +++ b/src/shapefile.py @@ -141,8 +141,6 @@ def tell(self): ... ] # A Possible value in a Shapefile record, e.g. L, N, F, C, D types - - class HasGeoInterface(Protocol): @property def __geo_interface__(self) -> Any: ... From e481bcde944e8c08fe2682b658af90b29fcf2970 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Sun, 27 Jul 2025 16:59:00 +0100 Subject: [PATCH 07/10] Change bbox in doctests to a tuple --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a8ed822..52b8de7 100644 --- a/README.md +++ b/README.md @@ -406,7 +406,7 @@ and the bounding box area the shapefile covers: >>> len(sf) 663 >>> sf.bbox - [-122.515048, 37.652916, -122.327622, 37.863433] + (-122.515048, 37.652916, -122.327622, 37.863433) Finally, if you would prefer to work with the entire shapefile in a different format, you can convert all of it to a GeoJSON dictionary, although you may lose From 243a3acb16253d3f0b5c9a8d39f34041aa2d6bd8 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Sun, 27 Jul 2025 17:00:29 +0100 Subject: [PATCH 08/10] Remove unused import --- src/shapefile.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/shapefile.py b/src/shapefile.py index 2152ee7..e3a84b1 100644 --- a/src/shapefile.py +++ b/src/shapefile.py @@ -34,7 +34,6 @@ Protocol, Reversible, Sequence, - TypedDict, TypeVar, Union, overload, From 4da8203d019af995233449a36cf080ce7aec19f7 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Sun, 27 Jul 2025 18:10:05 +0100 Subject: [PATCH 09/10] Type hint Writer."shape" methods --- src/shapefile.py | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/src/shapefile.py b/src/shapefile.py index e3a84b1..2c7d291 100644 --- a/src/shapefile.py +++ b/src/shapefile.py @@ -2801,15 +2801,13 @@ def multipoint(self, points: Coords): # nest the points inside a list to be compatible with the generic shapeparts method self._shapeparts(parts=[points], shapeType=shapeType) - def multipointm(self, points): + def multipointm(self, points: list[PointM]): """Creates a MULTIPOINTM shape. Points is a list of xym values. If the m (measure) value is not included, it defaults to None (NoData).""" shapeType = MULTIPOINTM - points = [ - points - ] # nest the points inside a list to be compatible with the generic shapeparts method - self._shapeparts(parts=points, shapeType=shapeType) + # nest the points inside a list to be compatible with the generic shapeparts method + self._shapeparts(parts=[points], shapeType=shapeType) def multipointz(self, points): """Creates a MULTIPOINTZ shape. @@ -2817,25 +2815,23 @@ def multipointz(self, points): If the z (elevation) value is not included, it defaults to 0. If the m (measure) value is not included, it defaults to None (NoData).""" shapeType = MULTIPOINTZ - points = [ - points - ] # nest the points inside a list to be compatible with the generic shapeparts method - self._shapeparts(parts=points, shapeType=shapeType) + # nest the points inside a list to be compatible with the generic shapeparts method + self._shapeparts(parts=[points], shapeType=shapeType) - def line(self, lines: Collection[Coords]): + def line(self, lines: list[Coords]): """Creates a POLYLINE shape. Lines is a collection of lines, each made up of a list of xy values.""" shapeType = POLYLINE self._shapeparts(parts=lines, shapeType=shapeType) - def linem(self, lines): + def linem(self, lines: list[Points]): """Creates a POLYLINEM shape. Lines is a collection of lines, each made up of a list of xym values. If the m (measure) value is not included, it defaults to None (NoData).""" shapeType = POLYLINEM self._shapeparts(parts=lines, shapeType=shapeType) - def linez(self, lines): + def linez(self, lines: list[Points]): """Creates a POLYLINEZ shape. Lines is a collection of lines, each made up of a list of xyzm values. If the z (elevation) value is not included, it defaults to 0. @@ -2843,7 +2839,7 @@ def linez(self, lines): shapeType = POLYLINEZ self._shapeparts(parts=lines, shapeType=shapeType) - def poly(self, polys: Collection[Coords]): + def poly(self, polys: list[Coords]): """Creates a POLYGON shape. Polys is a collection of polygons, each made up of a list of xy values. Note that for ordinary polygons the coordinates must run in a clockwise direction. @@ -2851,7 +2847,7 @@ def poly(self, polys: Collection[Coords]): shapeType = POLYGON self._shapeparts(parts=polys, shapeType=shapeType) - def polym(self, polys): + def polym(self, polys: list[Points]): """Creates a POLYGONM shape. Polys is a collection of polygons, each made up of a list of xym values. Note that for ordinary polygons the coordinates must run in a clockwise direction. @@ -2860,7 +2856,7 @@ def polym(self, polys): shapeType = POLYGONM self._shapeparts(parts=polys, shapeType=shapeType) - def polyz(self, polys): + def polyz(self, polys: list[Points]): """Creates a POLYGONZ shape. Polys is a collection of polygons, each made up of a list of xyzm values. Note that for ordinary polygons the coordinates must run in a clockwise direction. @@ -2870,7 +2866,7 @@ def polyz(self, polys): shapeType = POLYGONZ self._shapeparts(parts=polys, shapeType=shapeType) - def multipatch(self, parts, partTypes): + def multipatch(self, parts: list[list[PointZ]], partTypes: list[int]): """Creates a MULTIPATCH shape. Parts is a collection of 3D surface patches, each made up of a list of xyzm values. PartTypes is a list of types that define each of the surface patches. @@ -2886,11 +2882,12 @@ def multipatch(self, parts, partTypes): # set part index position polyShape.parts.append(len(polyShape.points)) # add points - for point in part: - # Ensure point is list - if not isinstance(point, list): - point = list(point) - polyShape.points.append(point) + # for point in part: + # # Ensure point is list + # if not isinstance(point, list): + # point = list(point) + # polyShape.points.append(point) + polyShape.points.extend(part) polyShape.partTypes = partTypes # write the shape self.shape(polyShape) From b012280a2e41610dabb91b6acbefdd2da09208a0 Mon Sep 17 00:00:00 2001 From: James Parrott <80779630+JamesParrott@users.noreply.github.com> Date: Sun, 27 Jul 2025 18:13:27 +0100 Subject: [PATCH 10/10] Remove unused import --- src/shapefile.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/shapefile.py b/src/shapefile.py index 2c7d291..0c1b401 100644 --- a/src/shapefile.py +++ b/src/shapefile.py @@ -24,7 +24,6 @@ from typing import ( IO, Any, - Collection, Container, Generic, Iterable,