diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f51d2cc..e3185de 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,6 +2,9 @@ name: Lint on: pull_request: +permissions: + contents: read + jobs: flake8: runs-on: ubuntu-latest @@ -9,10 +12,34 @@ jobs: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: - python-version: 3.8 + python-version: 3.9 - uses: TrueBrain/actions-flake8@v2 with: flake8_version: 6.0.0 max_line_length: 120 - plugins: flake8-isort==6.0.0 flake8-quotes==3.3.2 + plugins: flake8-isort==6.1.2 flake8-quotes==3.4.0 flake8-commas==4.0.0 + + mypy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.9 + + - run: | + pip install poetry + poetry install --with=mypy + + - run: | + poetry run mypy \ + --follow-untyped-imports \ + --disallow-any-unimported \ + --disallow-untyped-defs \ + --check-untyped-defs \ + --strict-equality \ + --warn-redundant-casts \ + --warn-unused-ignores \ + geo_extensions diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dcab4fe..a102f04 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,12 +3,15 @@ name: Test on: pull_request: +permissions: + contents: read + jobs: pytest: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ['3.9', '3.10', '3.11', '3.12'] fail-fast: false steps: diff --git a/README.md b/README.md index 23e6558..bb78401 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ functions. A transformation function is any function with the following signature: ```python -def transformation(polygon: Polygon) -> Generator[Polygon, None, None]: +def transformation(polygon: Polygon) -> Generator[Polygon]: ... ``` diff --git a/geo_extensions/transformations.py b/geo_extensions/transformations.py index 047ffac..1b0c9a2 100644 --- a/geo_extensions/transformations.py +++ b/geo_extensions/transformations.py @@ -38,7 +38,7 @@ ordered in the shapely flat space. """ -from typing import List, Tuple +from typing import cast import shapely.ops from shapely.geometry import LineString, Polygon @@ -50,8 +50,8 @@ ) from geo_extensions.types import Transformation, TransformationResult -Point = Tuple[float, float] -Bbox = List[Point] +Point = tuple[float, float] +Bbox = list[Point] ANTIMERIDIAN = LineString([(180, 90), (180, -90)]) @@ -63,7 +63,9 @@ def simplify_polygon(tolerance: float, preserve_topology: bool = True) -> Transf """ def simplify(polygon: Polygon) -> TransformationResult: """Perform a shapely simplify operation on the polygon.""" - yield polygon.simplify(tolerance, preserve_topology) + # NOTE(reweeden): I have been unable to produce a situation where a + # polygon is simplified to a geometry other than Polygon. + yield cast(Polygon, polygon.simplify(tolerance, preserve_topology)) return simplify @@ -139,7 +141,7 @@ def _shift_polygon(polygon: Polygon) -> Polygon: return Polygon([ ((360.0 + lon) % 360, lat) - for lon, lat in polygon.boundary.coords + for lon, lat in polygon.exterior.coords ]) @@ -149,7 +151,7 @@ def _shift_polygon_back(polygon: Polygon) -> Polygon: _, _, max_lon, _ = polygon.bounds return Polygon([ (_adjust_lon(lon, max_lon), lat) - for lon, lat in polygon.boundary.coords + for lon, lat in polygon.exterior.coords ]) @@ -165,13 +167,13 @@ def _adjust_lon(lon: float, max_lon: float) -> float: def _split_polygon( polygon: Polygon, line: LineString, -) -> List[Polygon]: +) -> list[Polygon]: split_collection = shapely.ops.split(polygon, line) return [ - orient(poly) - for poly in split_collection.geoms - if not _ignore_polygon(poly) + orient(geom) + for geom in split_collection.geoms + if isinstance(geom, Polygon) and not _ignore_polygon(geom) ] diff --git a/geo_extensions/transformer.py b/geo_extensions/transformer.py index befc67d..f9dd713 100644 --- a/geo_extensions/transformer.py +++ b/geo_extensions/transformer.py @@ -1,4 +1,4 @@ -from typing import Iterable, List, Sequence, Tuple +from collections.abc import Iterable, Sequence from shapely import Geometry, wkt from shapely.geometry import MultiPolygon, Polygon, shape @@ -12,7 +12,7 @@ class Transformer: def __init__(self, transformations: Sequence[Transformation]): self.transformations = transformations - def from_geo_json(self, geo_json: dict) -> List[Polygon]: + def from_geo_json(self, geo_json: dict) -> list[Polygon]: """Load and transform an object from a GeoJSON dict. :returns: a list of transformed polygons @@ -24,7 +24,7 @@ def from_geo_json(self, geo_json: dict) -> List[Polygon]: return self.transform(polygons) - def from_wkt(self, wkt_str: str) -> List[Polygon]: + def from_wkt(self, wkt_str: str) -> list[Polygon]: """Load and transform an object from a WKT string. :returns: a list of transformed polygons @@ -36,7 +36,7 @@ def from_wkt(self, wkt_str: str) -> List[Polygon]: return self.transform(polygons) - def transform(self, polygons: Iterable[Polygon]) -> List[Polygon]: + def transform(self, polygons: Iterable[Polygon]) -> list[Polygon]: """Perform the transformation chain on a sequence of polygons. :returns: a list of transformed polygons @@ -46,7 +46,7 @@ def transform(self, polygons: Iterable[Polygon]) -> List[Polygon]: _apply_transformations( polygons, tuple(self.transformations), - ) + ), ) @@ -70,7 +70,7 @@ def to_polygons(obj: Geometry) -> TransformationResult: def _apply_transformations( polygons: Iterable[Polygon], - transformations: Tuple[Transformation, ...], + transformations: tuple[Transformation, ...], ) -> TransformationResult: if not transformations: yield from polygons diff --git a/geo_extensions/types.py b/geo_extensions/types.py index 937c3e0..778cf95 100644 --- a/geo_extensions/types.py +++ b/geo_extensions/types.py @@ -1,6 +1,6 @@ -from typing import Callable, Generator +from collections.abc import Callable, Generator from shapely.geometry import Polygon -TransformationResult = Generator[Polygon, None, None] +TransformationResult = Generator[Polygon] Transformation = Callable[[Polygon], TransformationResult] diff --git a/pyproject.toml b/pyproject.toml index 3c1a774..7e248ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,12 +12,17 @@ readme = "README.md" packages = [{include = "geo_extensions"}] [tool.poetry.dependencies] -python = "^3.8" +python = "^3.9" + shapely = "^2.0.3" [tool.poetry.group.dev.dependencies] pytest = "^8.0.1" hypothesis = "^6.98.8" +[tool.poetry.group.mypy.dependencies] +mypy = "^1.16.1" +types-shapely = "^2.0.3" + [tool.isort] profile = "black" diff --git a/tests/test_geo_extensions.py b/tests/test_geo_extensions.py index 4f5542f..7b31b25 100644 --- a/tests/test_geo_extensions.py +++ b/tests/test_geo_extensions.py @@ -14,5 +14,5 @@ def test_default_transformer(data_path): (-68.93161319601728, 81.16999707795055), (-64.74688665108201, 80.78217738556877), (-64.18242781701073, 80.92318071697005), - ]) + ]), ] diff --git a/tests/test_transformations.py b/tests/test_transformations.py index 5c207fb..7df1a51 100644 --- a/tests/test_transformations.py +++ b/tests/test_transformations.py @@ -6,11 +6,54 @@ from geo_extensions.transformations import ( drop_z_coordinate, + simplify_polygon, split_polygon_on_antimeridian_ccw, split_polygon_on_antimeridian_fixed_size, ) +def test_simplify(): + polygon = Polygon([ + (20, 0), + (20, 0), + (20, 10), + (0, 10), + (0, 0), + (20, 0), + ]) + assert list(simplify_polygon(0.01)(polygon)) == [ + Polygon([ + (20, 0), + (20, 10), + (0, 10), + (0, 0), + (20, 0), + ]), + ] + + +def test_simplify_line(): + polygon = Polygon([ + (20, 0), + (20, 10), + (20, 10), + (20, 0), + (20, 0), + ]) + assert list(simplify_polygon(0.01)(polygon)) == [ + Polygon([ + (20, 0), + (20, 10), + (20, 10), + (20, 0), + ]), + ] + + assert list(simplify_polygon(0.01, preserve_topology=False)(polygon)) == [ + Polygon([]), + ] + + def test_drop_z_coordinate(): polygon = Polygon([ (180, 1, 10), @@ -26,7 +69,7 @@ def test_drop_z_coordinate(): (-179.999, 0), (-179.999, 1), (180, 1), - ]) + ]), ] @@ -53,7 +96,7 @@ def test_split_polygon_on_antimeridian_ccw_returns_ccw(polygon): polygon=strategies.rectangles( # Very small polygons near the antimeridian will be culled. lons=st.floats(min_value=-179.990, max_value=180), - ) + ), ) @settings(suppress_health_check=[HealthCheck.filter_too_much]) def test_split_polygon_on_antimeridian_ccw_returns_non_empty_list(polygon): @@ -64,7 +107,7 @@ def test_split_polygon_on_antimeridian_ccw_returns_empty_list(): # There is a case where the input polygon is really small, and both split # parts are culled. polygon = Polygon([ - (180, 1), (180, 0), (-179.999, 0), (-179.999, 1), (180, 1) + (180, 1), (180, 0), (-179.999, 0), (-179.999, 1), (180, 1), ]) assert list(split_polygon_on_antimeridian_ccw(polygon)) == [] @@ -137,7 +180,7 @@ def test_split_polygon_on_antimeridian_ccw_west(): """Polygon is mostly west of the IDL""" polygon = Polygon([ (170., 70.), (170., 60.), (-179., 60.), - (-179., 70.), (170., 70.) + (-179., 70.), (170., 70.), ]) assert not polygon.exterior.is_ccw polygons = list(split_polygon_on_antimeridian_ccw(polygon)) @@ -168,7 +211,7 @@ def test_split_polygon_on_antimeridian_ccw_east(): """Polygon is mostly east of the IDL""" polygon = Polygon([ (179., 70.), (179., 60.), (-170., 60.), - (-170., 70.), (179., 70.) + (-170., 70.), (179., 70.), ]) assert not polygon.exterior.is_ccw polygons = list(split_polygon_on_antimeridian_ccw(polygon)) @@ -200,7 +243,7 @@ def test_split_polygon_on_antimeridian_ccw_close_point(): polygon = Polygon([ (179.999999, 70.), (179., 60.), (-170., 60.), - (-170., 70.), (179., 70.) + (-170., 70.), (179., 70.), ]) assert not polygon.exterior.is_ccw polygons = list(split_polygon_on_antimeridian_ccw(polygon)) diff --git a/tests/test_transformer.py b/tests/test_transformer.py index d792ddc..9f559f3 100644 --- a/tests/test_transformer.py +++ b/tests/test_transformer.py @@ -24,7 +24,7 @@ def test_from_wkt(simplify_transformer): (51.0, 21.0), (51.0, 20.0), (50.0, 20.0), - ]) + ]), ] assert simplify_transformer.from_wkt( "POLYGON(( 1 1, 2 1, 1 2, 1 1))", @@ -34,7 +34,7 @@ def test_from_wkt(simplify_transformer): (2, 1), (1, 2), (1, 1), - ]) + ]), ] # Duplicate point is removed assert simplify_transformer.from_wkt( @@ -46,14 +46,14 @@ def test_from_wkt(simplify_transformer): (51.0, 21.0), (51.0, 20.0), (50.0, 20.0), - ]) + ]), ] def test_from_wkt_multipolygon(simplify_transformer): assert simplify_transformer.from_wkt( "MULTIPOLYGON (((30 20, 45 40, 10 40, 30 20))," - "((15 5, 40 10, 10 20, 5 10, 15 5)))" + "((15 5, 40 10, 10 20, 5 10, 15 5)))", ) == [ Polygon([ (30.0, 20.0),