Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run_checks_build_and_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
python -m pip install --upgrade pip
pip install pytest pylint pylint-per-file-ignores
pip install -e .
- name: run Pylint for errors and warnings only
- name: run Pylint for errors, warnings and remarks only (ignore Comments/ Code style)
run: |
pylint --disable=C test_shapefile.py src/shapefile.py

Expand Down
173 changes: 130 additions & 43 deletions src/shapefile.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,13 @@
Generic,
Iterable,
Iterator,
Literal,
NoReturn,
Optional,
Protocol,
Reversible,
Sequence,
TypedDict,
TypeVar,
Union,
overload,
Expand Down Expand Up @@ -111,7 +113,7 @@
PointM = tuple[float, float, Optional[float]]
PointZ = tuple[float, float, float, Optional[float]]

Coord = Union[Point2D, Point2D, Point3D]
Coord = Union[Point2D, Point3D]
Coords = list[Coord]

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


class GeoJSONPoint(TypedDict):
type: Literal["Point"]
# We fix to a tuple (to statically check the length is 2, 3 or 4) but
# RFC7946 only requires: "A position is an array of numbers. There MUST be two or more
# elements. "
# RFC7946 also requires long/lat easting/northing which we do not enforce,
# and despite the SHOULD NOT, we may use a 4th element for Shapefile M Measures.
coordinates: Union[Point, tuple[()]]


class GeoJSONMultiPoint(TypedDict):
type: Literal["MultiPoint"]
coordinates: Points


class GeoJSONLineString(TypedDict):
type: Literal["LineString"]
# "Two or more positions" not enforced by type checker
# https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.4
coordinates: Points


class GeoJSONMultiLineString(TypedDict):
type: Literal["MultiLineString"]
coordinates: list[Points]


class GeoJSONPolygon(TypedDict):
type: Literal["Polygon"]
# Other requirements for Polygon not enforced by type checker
# https://datatracker.ietf.org/doc/html/rfc7946#section-3.1.6
coordinates: list[Points]


class GeoJSONMultiPolygon(TypedDict):
type: Literal["MultiPolygon"]
coordinates: list[list[Points]]


GeoJSONHomogeneousGeometryObject = Union[
GeoJSONPoint,
GeoJSONMultiPoint,
GeoJSONLineString,
GeoJSONMultiLineString,
GeoJSONPolygon,
GeoJSONMultiPolygon,
]


class GeoJSONGeometryCollection(TypedDict):
type: Literal["GeometryCollection"]
geometries: list[GeoJSONHomogeneousGeometryObject]


# RFC7946 3.1
GeoJSONObject = Union[GeoJSONHomogeneousGeometryObject, GeoJSONGeometryCollection]


class GeoJSONFeature(TypedDict):
type: Literal["Feature"]
properties: Optional[
dict[str, Any]
] # RFC7946 3.2 "(any JSON object or a JSON null value)"
geometry: Optional[GeoJSONObject]


class GeoJSONFeatureCollection(TypedDict):
type: Literal["FeatureCollection"]
features: list[GeoJSONFeature]


class GeoJSONFeatureCollectionWithBBox(GeoJSONFeatureCollection, total=False):
# bbox is optional
# typing.NotRequired requires Python 3.11
# and we must support 3.9 (at least until October)
# https://docs.python.org/3/library/typing.html#typing.Required
# Is there a backport?
bbox: list[float]


# Helpers

MISSING = [None, ""]
Expand Down Expand Up @@ -211,7 +293,7 @@ def __repr__(self):


def signed_area(
coords: Coords,
coords: Points,
fast: bool = False,
) -> float:
"""Return the signed area enclosed by a ring using the linear time
Expand All @@ -229,22 +311,22 @@ def signed_area(
return area2 / 2.0


def is_cw(coords: Coords) -> bool:
def is_cw(coords: Points) -> bool:
"""Returns True if a polygon ring has clockwise orientation, determined
by a negatively signed area.
"""
area2 = signed_area(coords, fast=True)
return area2 < 0


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


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

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


def ring_contains_point(coords: Coords, p: Point2D) -> bool:
def ring_contains_point(coords: Points, p: Point2D) -> bool:
"""Fast point-in-polygon crossings algorithm, MacMartin optimization.

Adapted from code by Eric Haynes
Expand Down Expand Up @@ -314,7 +396,7 @@ class RingSamplingError(Exception):
pass


def ring_sample(coords: Coords, ccw: bool = False) -> Point2D:
def ring_sample(coords: Points, 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.
Expand Down Expand Up @@ -364,14 +446,15 @@ def itercoords():
)


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


def organize_polygon_rings(
rings: Iterable[Coords], return_errors: Optional[dict[str, int]] = None
) -> list[list[Coords]]:
rings: Iterable[Points], return_errors: Optional[dict[str, int]] = None
) -> list[list[Points]]:
"""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),
Expand Down Expand Up @@ -541,7 +624,7 @@ def __init__(
# self.bbox: Optional[_Array[float]] = None

@property
def __geo_interface__(self):
def __geo_interface__(self) -> GeoJSONHomogeneousGeometryObject:
if self.shapeType in [POINT, POINTM, POINTZ]:
# point
if len(self.points) == 0:
Expand Down Expand Up @@ -922,17 +1005,19 @@ def __init__(self, shape: Optional[Shape] = None, record: Optional[_Record] = No
self.record = record

@property
def __geo_interface__(self):
def __geo_interface__(self) -> GeoJSONFeature:
return {
"type": "Feature",
"properties": self.record.as_dict(date_strings=True),
"properties": None
if self.record is None
else self.record.as_dict(date_strings=True),
"geometry": None
if self.shape.shapeType == NULL
if self.shape is None or self.shape.shapeType == NULL
else self.shape.__geo_interface__,
}


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

@property
def __geo_interface__(self):
def __geo_interface__(self) -> GeoJSONGeometryCollection:
# Note: currently this will fail if any of the shapes are null-geometries
# could be fixed by storing the shapefile shapeType upon init, returning geojson type with empty coords
collection = {
"type": "GeometryCollection",
"geometries": [shape.__geo_interface__ for shape in self],
}
collection = GeoJSONGeometryCollection(
type="GeometryCollection",
geometries=[shape.__geo_interface__ for shape in self if shape is not None],
)
return collection


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

@property
def __geo_interface__(self):
collection = {
"type": "FeatureCollection",
"features": [shaperec.__geo_interface__ for shaperec in self],
}
return collection
def __geo_interface__(self) -> GeoJSONFeatureCollection:
return GeoJSONFeatureCollection(
type="FeatureCollection",
features=[shaperec.__geo_interface__ for shaperec in self],
)


class ShapefileException(Exception):
Expand Down Expand Up @@ -1284,10 +1368,12 @@ def __iter__(self):
yield from self.iterShapeRecords()

@property
def __geo_interface__(self):
def __geo_interface__(self) -> GeoJSONFeatureCollectionWithBBox:
shaperecords = self.shapeRecords()
fcollection = shaperecords.__geo_interface__
fcollection["bbox"] = list(self.bbox)
fcollection = GeoJSONFeatureCollectionWithBBox(
bbox=list(self.bbox),
**shaperecords.__geo_interface__,
)
return fcollection

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

def multipoint(self, points: Coords):
def multipoint(self, points: Points):
"""Creates a MULTIPOINT shape.
Points is a list of xy values."""
shapeType = MULTIPOINT
# nest the points inside a list to be compatible with the generic shapeparts method
self._shapeparts(parts=[points], shapeType=shapeType)

def multipointm(self, points: list[PointM]):
def multipointm(self, points: Points):
"""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
# nest the points inside a list to be compatible with the generic shapeparts method
self._shapeparts(parts=[points], shapeType=shapeType)

def multipointz(self, points):
def multipointz(self, points: Points):
"""Creates a MULTIPOINTZ shape.
Points is a list of xyzm values.
If the z (elevation) value is not included, it defaults to 0.
Expand All @@ -2817,7 +2903,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: list[Coords]):
def line(self, lines: list[Points]):
"""Creates a POLYLINE shape.
Lines is a collection of lines, each made up of a list of xy values."""
shapeType = POLYLINE
Expand All @@ -2838,7 +2924,7 @@ def linez(self, lines: list[Points]):
shapeType = POLYLINEZ
self._shapeparts(parts=lines, shapeType=shapeType)

def poly(self, polys: list[Coords]):
def poly(self, polys: list[Points]):
"""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.
Expand All @@ -2865,7 +2951,7 @@ def polyz(self, polys: list[Points]):
shapeType = POLYGONZ
self._shapeparts(parts=polys, shapeType=shapeType)

def multipatch(self, parts: list[list[PointZ]], partTypes: list[int]):
def multipatch(self, parts: list[Points], 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.
Expand All @@ -2891,7 +2977,7 @@ def multipatch(self, parts: list[list[PointZ]], partTypes: list[int]):
# write the shape
self.shape(polyShape)

def _shapeparts(self, parts, shapeType):
def _shapeparts(self, parts: list[Points], shapeType: int):
"""Internal method for adding a shape that has multiple collections of points (parts):
lines, polygons, and multipoint shapes.
"""
Expand All @@ -2908,10 +2994,11 @@ def _shapeparts(self, parts, shapeType):
# set part index position
polyShape.parts.append(len(polyShape.points))
# add points
for point in part:
# Ensure point is list
point_list = list(point)
polyShape.points.append(point_list)
# for point in part:
# # Ensure point is list
# point_list = list(point)
# polyShape.points.append(point_list)
polyShape.points.extend(part)
# write the shape
self.shape(polyShape)

Expand Down
Loading