diff --git a/.github/workflows/run-all-tests.yml b/.github/workflows/run-all-tests.yml index b1602d8..3cb4122 100644 --- a/.github/workflows/run-all-tests.yml +++ b/.github/workflows/run-all-tests.yml @@ -44,7 +44,7 @@ jobs: python -m pip install -e ".[tests]" - name: Test with pytest including hypothesis tests run: >- - pytest tests + pytest tests --hypothesis-profile=ci test-coverage: runs-on: ubuntu-latest @@ -92,7 +92,7 @@ jobs: python -m pip install -e ".[typing, complexity, linting]" - name: Typecheck run: | - mypy pygeoif + mypy pygeoif tests - name: Linting run: | flake8 pygeoif diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6a971e4..e55cf0a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,7 +4,7 @@ repos: hooks: - id: check-useless-excludes - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: check-added-large-files - id: check-docstring-first @@ -36,11 +36,11 @@ repos: hooks: - id: isort - repo: https://github.com/psf/black - rev: 24.3.0 + rev: 24.4.2 hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.3.4' + rev: 'v0.4.3' hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] @@ -68,7 +68,7 @@ repos: - flake8-typing-imports - flake8-use-fstring - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.9.0 + rev: v1.10.0 hooks: - id: mypy additional_dependencies: @@ -86,7 +86,7 @@ repos: hooks: - id: pyprojectsort - repo: https://github.com/python-jsonschema/check-jsonschema - rev: "0.28.0" + rev: "0.28.2" hooks: - id: check-github-workflows - id: check-github-actions diff --git a/README.rst b/README.rst index c83934b..cddaa76 100644 --- a/README.rst +++ b/README.rst @@ -38,59 +38,61 @@ testing with Hypothesis_. It was written to provide clean and python only geometries for fastkml_ -.. image:: https://readthedocs.org/projects/pygeoif/badge/?version=latest +|doc| |test| |cov| |hypothesis| |black| |mypy| |openhub| |factor| |commit| |py| |implement| |latest| |license| |downloads| + +.. |doc| image:: https://readthedocs.org/projects/pygeoif/badge/?version=latest :target: https://pygeoif.readthedocs.io/en/latest/?badge=latest :alt: Documentation -.. image:: https://github.com/cleder/pygeoif/actions/workflows/run-all-tests.yml/badge.svg?branch=main +.. |test| image:: https://github.com/cleder/pygeoif/actions/workflows/run-all-tests.yml/badge.svg?branch=main :target: https://github.com/cleder/pygeoif/actions/workflows/run-all-tests.yml :alt: GitHub Actions -.. image:: https://codecov.io/gh/cleder/pygeoif/branch/main/graph/badge.svg?token=2EfiwBXs9X +.. |cov| image:: https://codecov.io/gh/cleder/pygeoif/branch/main/graph/badge.svg?token=2EfiwBXs9X :target: https://codecov.io/gh/cleder/pygeoif :alt: Codecov -.. image:: https://img.shields.io/badge/property_based_tests-hypothesis-green - :target: https://hypothesis.works - :alt: Hypothesis +.. |hypothesis| image:: https://img.shields.io/badge/hypothesis-tested-brightgreen.svg + :alt: Tested with Hypothesis + :target: https://hypothesis.readthedocs.io -.. image:: https://img.shields.io/badge/code_style-black-000000.svg +.. |black| image:: https://img.shields.io/badge/code_style-black-000000.svg :target: https://github.com/psf/ :alt: Black -.. image:: https://img.shields.io/badge/type_checker-mypy-blue +.. |mypy| image:: https://img.shields.io/badge/type_checker-mypy-blue :target: http://mypy-lang.org/ :alt: Mypy -.. image:: https://www.openhub.net/p/pygeoif/widgets/project_thin_badge.gif +.. |openhub| image:: https://www.openhub.net/p/pygeoif/widgets/project_thin_badge.gif :target: https://www.openhub.net/p/pygeoif/ :alt: Openhub -.. image:: https://www.codefactor.io/repository/github/cleder/pygeoif/badge/main +.. |factor| image:: https://www.codefactor.io/repository/github/cleder/pygeoif/badge/main :target: https://www.codefactor.io/repository/github/cleder/pygeoif/overview/main :alt: CodeFactor -.. image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit +.. |commit| image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit :target: https://github.com/pre-commit/pre-commit :alt: pre-commit -.. image:: https://img.shields.io/pypi/pyversions/pygeoif.svg +.. |py| image:: https://img.shields.io/pypi/pyversions/pygeoif.svg :target: https://pypi.python.org/pypi/pygeoif/ :alt: Supported Python versions -.. image:: https://img.shields.io/pypi/implementation/pygeoif.svg +.. |implement| image:: https://img.shields.io/pypi/implementation/pygeoif.svg :target: https://pypi.python.org/pypi/pygeoif/ :alt: Supported Python implementations -.. image:: https://img.shields.io/pypi/v/pygeoif.svg +.. |latest| image:: https://img.shields.io/pypi/v/pygeoif.svg :target: https://pypi.python.org/pypi/pygeoif/ :alt: Latest Version -.. image:: https://img.shields.io/pypi/l/pygeoif.svg +.. |license| image:: https://img.shields.io/pypi/l/pygeoif.svg :target: https://pypi.python.org/pypi/pygeoif/ :alt: License -.. image:: https://img.shields.io/pypi/dm/pygeoif.svg +.. |downloads| image:: https://img.shields.io/pypi/dm/pygeoif.svg :target: https://pypi.python.org/pypi/pygeoif/ :alt: Downloads @@ -104,6 +106,7 @@ You can install PyGeoIf from pypi using pip:: Example ======== +.. code-block:: pycon >>> from pygeoif import geometry >>> p = geometry.Point(1,1) @@ -159,15 +162,16 @@ x, y, z : float Example ~~~~~~~~ +.. code-block: pycon - >>> from pygeoif import Point - >>> p = Point(1.0, -1.0) - >>> print(p) - POINT (1.0 -1.0) - >>> p.y - -1.0 - >>> p.x - 1.0 + >>> from pygeoif import Point + >>> p = Point(1.0, -1.0) + >>> print(p) + POINT (1.0 -1.0) + >>> p.y + -1.0 + >>> p.x + 1.0 @@ -267,6 +271,7 @@ So it's very rarely used in the real GIS professional world. Example ~~~~~~~~ +.. code-block:: pycon >>> from pygeoif import geometry >>> p = geometry.Point(1.0, -1.0) @@ -289,6 +294,8 @@ properties : dict Example ~~~~~~~~ +.. code-block:: pycon + >>> from pygeoif import Point, Feature >>> p = Point(1.0, -1.0) >>> props = {'Name': 'Sample Point', 'Other': 'Other Data'} @@ -309,6 +316,7 @@ features: sequence Example ~~~~~~~~ +.. code-block:: pycon >>> from pygeoif import Point, Feature, FeatureCollection >>> p = Point(1.0, -1.0) @@ -331,6 +339,8 @@ shape Create a pygeoif feature from an object that provides the ``__geo_interface__`` or any GeoJSON_ compatible dictionary. +.. code-block:: pycon + >>> from shapely.geometry import Point >>> from pygeoif import geometry, shape >>> shape(Point(0,0)) @@ -342,6 +352,8 @@ from_wkt Create a geometry from its WKT representation +.. code-block:: pycon + >>> from pygeoif import from_wkt >>> p = from_wkt('POINT (0 1)') >>> print(p) diff --git a/docs/HISTORY.rst b/docs/HISTORY.rst index 23935c1..6abe02e 100644 --- a/docs/HISTORY.rst +++ b/docs/HISTORY.rst @@ -1,6 +1,12 @@ Changelog ========= +1.5.0 (2024/05/11) +------------------ + +- fix handling of empty geometries. +- more hypothesis tests + 1.4.0 (2024/03/25) ------------------ diff --git a/docs/conf.py b/docs/conf.py index f905846..f7c5f5a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,4 +1,5 @@ # noqa: D100, INP001 +# flake8: noqa # Configuration file for the Sphinx documentation builder. # # For the full list of built-in configuration values, see the documentation: @@ -11,6 +12,7 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + from pygeoif import about project = "pygeoif" diff --git a/pygeoif/about.py b/pygeoif/about.py index e274e4d..6cbc91e 100644 --- a/pygeoif/about.py +++ b/pygeoif/about.py @@ -4,4 +4,4 @@ The only purpose of this module is to provide a version number for the package. """ -__version__ = "1.4.0" +__version__ = "1.5.0" diff --git a/pygeoif/factories.py b/pygeoif/factories.py index 660f722..c139434 100644 --- a/pygeoif/factories.py +++ b/pygeoif/factories.py @@ -150,15 +150,12 @@ def shape( ) raise TypeError(msg) - constructor = type_map.get(geometry["type"]) - if constructor: + if constructor := type_map.get(geometry["type"]): return constructor._from_dict( # type: ignore [attr-defined, no-any-return] geometry, ) if geometry["type"] == "GeometryCollection": - geometries = [ - shape(fi) for fi in geometry["geometries"] # type: ignore [typeddict-item] - ] + geometries = [shape(fi) for fi in geometry["geometries"]] return GeometryCollection(geometries) msg = f"[{geometry['type']} is not implemented" raise NotImplementedError(msg) diff --git a/pygeoif/functions.py b/pygeoif/functions.py index 61a9a1c..0e12b2c 100644 --- a/pygeoif/functions.py +++ b/pygeoif/functions.py @@ -41,6 +41,8 @@ def signed_area(coords: LineType) -> float: Linear time algorithm: http://www.cgafaq.info/wiki/Polygon_Area. A value >= 0 indicates a counter-clockwise oriented ring. """ + if len(coords) < 3: # noqa: PLR2004 + return 0.0 xs, ys = map(list, zip(*(coord[:2] for coord in coords))) xs.append(xs[1]) # pragma: no mutate ys.append(ys[1]) # pragma: no mutate @@ -53,7 +55,6 @@ def signed_area(coords: LineType) -> float: def centroid(coords: LineType) -> Tuple[Point2D, float]: """Calculate the coordinates of the centroid and the area of a LineString.""" ans: List[float] = [0, 0] - n = len(coords) signed_area = 0.0 @@ -68,8 +69,11 @@ def centroid(coords: LineType) -> Tuple[Point2D, float]: ans[0] += (coord[0] + next_coord[0]) * area ans[1] += (coord[1] + next_coord[1]) * area - ans[0] = (ans[0]) / (3 * signed_area) - ans[1] = (ans[1]) / (3 * signed_area) + if signed_area == 0 or math.isnan(signed_area): + return ((math.nan, math.nan), signed_area) + + ans[0] = ans[0] / (3 * signed_area) + ans[1] = ans[1] / (3 * signed_area) return cast(Point2D, tuple(ans)), signed_area / 2.0 @@ -167,13 +171,13 @@ def compare_geo_interface( return all( compare_geo_interface(first=g1, second=g2) # type: ignore [arg-type] for g1, g2 in zip_longest( - first["geometries"], # type: ignore [typeddict-item] + first["geometries"], second["geometries"], # type: ignore [typeddict-item] fillvalue={"type": None, "coordinates": ()}, ) ) return compare_coordinates( - coords=first["coordinates"], # type: ignore [typeddict-item] + coords=first["coordinates"], other=second["coordinates"], # type: ignore [typeddict-item] ) except KeyError: @@ -220,6 +224,8 @@ def move_coordinates( >>> move_coordinates(((0, 0), (-1, 1)), (-1, 1, 0)) ((-1, 1, 0), (-2, 2, 0)) """ + if not coordinates: + return coordinates if isinstance(coordinates[0], (int, float)): return move_coordinate(cast(PointType, coordinates), move_by) return cast( @@ -237,14 +243,13 @@ def move_geo_interface( return { "type": "GeometryCollection", "geometries": tuple( - move_geo_interface(g, move_by) - for g in interface["geometries"] # type: ignore [typeddict-item] + move_geo_interface(g, move_by) for g in interface["geometries"] ), } return { "type": interface["type"], "coordinates": move_coordinates( - interface["coordinates"], # type: ignore [typeddict-item, arg-type] + interface["coordinates"], # type: ignore [arg-type] move_by, ), } diff --git a/pygeoif/geometry.py b/pygeoif/geometry.py index c500ebb..4d8d82a 100644 --- a/pygeoif/geometry.py +++ b/pygeoif/geometry.py @@ -41,6 +41,7 @@ from pygeoif.types import Bounds from pygeoif.types import GeoCollectionInterface from pygeoif.types import GeoInterface +from pygeoif.types import GeomType from pygeoif.types import GeoType from pygeoif.types import LineType from pygeoif.types import Point2D @@ -166,7 +167,7 @@ def __geo_interface__(self) -> GeoInterface: msg = "Empty Geometry" raise AttributeError(msg) return { - "type": self.geom_type, + "type": cast(GeomType, self.geom_type), "bbox": cast(Bounds, self.bounds), "coordinates": (), } @@ -248,21 +249,17 @@ def __init__(self, x: float, y: float, z: Optional[float] = None) -> None: Easting, northing, and elevation. """ + geoms = (x, y, z) if z is not None else (x, y) object.__setattr__( self, "_geoms", - cast( - PointType, - tuple( - coordinate - for coordinate in (x, y, z) - if coordinate is not None and not math.isnan(coordinate) - ), - ), + geoms, ) def __repr__(self) -> str: """Return the representation.""" + if self.is_empty: + return f"{self.geom_type}()" return f"{self.geom_type}{self._geoms}" @property @@ -270,9 +267,9 @@ def is_empty(self) -> bool: """ Return if this geometry is empty. - A Point is considered empty when it has fewer than 2 coordinates. + A Point is considered empty when it has no valid coordinates. """ - return len(self._geoms) < 2 # noqa: PLR2004 + return any(coord is None or math.isnan(coord) for coord in self._geoms) @property def x(self) -> float: @@ -293,9 +290,9 @@ def z(self) -> Optional[float]: raise DimensionError(msg) @property - def coords(self) -> Tuple[PointType]: + def coords(self) -> Union[Tuple[PointType], Tuple[()]]: """Return the geometry coordinates.""" - return (self._geoms,) + return () if self.is_empty else (self._geoms,) @property def has_z(self) -> bool: @@ -376,7 +373,10 @@ def geoms(self) -> Tuple[Point, ...]: @property def coords(self) -> LineType: """Return the geometry coordinates.""" - return cast(LineType, tuple(point.coords[0] for point in self.geoms)) + return cast( + LineType, + tuple(point.coords[0] for point in self.geoms if point.coords), + ) @property def is_empty(self) -> bool: @@ -411,7 +411,9 @@ def from_coordinates(cls, coordinates: LineType) -> "LineString": @classmethod def from_points(cls, *args: Point) -> "LineString": """Create a linestring from points.""" - return cls(cast(LineType, tuple(point.coords[0] for point in args))) + return cls( + cast(LineType, tuple(point.coords[0] for point in args if point.coords)), + ) @classmethod def _from_dict(cls, geo_interface: GeoInterface) -> "LineString": @@ -480,9 +482,9 @@ def centroid(self) -> Optional[Point]: if self.has_z: msg = "Centeroid is only implemented for 2D coordinates" raise DimensionError(msg) - try: - cent, area = centroid(self.coords) - except ZeroDivisionError: + + cent, area = centroid(self.coords) + if any(math.isnan(coord) for coord in cent): return None return ( Point(x=cent[0], y=cent[1]) @@ -624,6 +626,8 @@ def from_linear_rings(cls, shell: LinearRing, *args: LinearRing) -> "Polygon": @classmethod def _from_dict(cls, geo_interface: GeoInterface) -> "Polygon": cls._check_dict(geo_interface) + if not geo_interface["coordinates"]: + return cls(shell=(), holes=()) return cls( shell=cast(LineType, geo_interface["coordinates"][0]), holes=cast(Tuple[LineType], geo_interface["coordinates"][1:]), @@ -733,7 +737,10 @@ def __len__(self) -> int: def __repr__(self) -> str: """Return the representation.""" - return f"{self.geom_type}({tuple(geom.coords[0] for geom in self._geoms)})" + return ( + f"{self.geom_type}" + f"({tuple(geom.coords[0] for geom in self._geoms if geom.coords)})" + ) @property def geoms(self) -> Iterator[Point]: @@ -748,13 +755,15 @@ def _wkt_coords(self) -> str: def __geo_interface__(self) -> GeoInterface: """Return the geo interface.""" geo_interface = super().__geo_interface__ - geo_interface["coordinates"] = tuple(geom.coords[0] for geom in self.geoms) + geo_interface["coordinates"] = tuple( + geom.coords[0] for geom in self.geoms if geom.coords + ) return geo_interface @classmethod def from_points(cls, *args: Point, unique: bool = False) -> "MultiPoint": """Create a MultiPoint from Points.""" - return cls([point.coords[0] for point in args], unique=unique) + return cls([point.coords[0] for point in args if point.coords], unique=unique) @classmethod def _from_dict(cls, geo_interface: GeoInterface) -> "MultiPoint": diff --git a/pygeoif/types.py b/pygeoif/types.py index f12ec89..c933a1b 100644 --- a/pygeoif/types.py +++ b/pygeoif/types.py @@ -57,11 +57,21 @@ ] MultiCoordinatesType = Sequence[CoordinatesType] +GeomType = Literal[ + "Point", + "LineString", + "LinearRing", + "Polygon", + "MultiPoint", + "MultiLineString", + "MultiPolygon", +] + class GeoInterface(TypedDict): """Required keys for the GeoInterface.""" - type: str + type: GeomType coordinates: Union[CoordinatesType, MultiCoordinatesType] bbox: NotRequired[Bounds] diff --git a/pyproject.toml b/pyproject.toml index 5ada748..69daa01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ authors = [ ] classifiers = [ "Development Status :: 5 - Production/Stable", + "Framework :: Hypothesis", "Intended Audience :: Developers", "License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)", "Operating System :: OS Independent", @@ -35,6 +36,7 @@ dynamic = [ ] keywords = [ "GIS", + "Hypothesis", "Spatial", "WKT", ] @@ -83,6 +85,7 @@ linting = [ ] tests = [ "hypothesis", + "more_itertools", "pytest", "pytest-cov", ] diff --git a/tests/conftest.py b/tests/conftest.py index 7a4dc90..b54dbc5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,9 +8,14 @@ from hypothesis import HealthCheck from hypothesis import settings -settings.register_profile("exhaustive", max_examples=10_000) +settings.register_profile( + "exhaustive", + max_examples=10_000, + suppress_health_check=[HealthCheck.too_slow], +) settings.register_profile( "coverage", max_examples=10, suppress_health_check=[HealthCheck.too_slow], ) +settings.register_profile("ci", suppress_health_check=[HealthCheck.too_slow]) diff --git a/tests/hypothesis/test_functions.py b/tests/hypothesis/test_functions.py new file mode 100644 index 0000000..b9f816a --- /dev/null +++ b/tests/hypothesis/test_functions.py @@ -0,0 +1,322 @@ +"""Hypothesis test cases for the `pygeoif.functions` module.""" + +# This test code was written by the `hypothesis.extra.ghostwriter` module +# and is provided under the Creative Commons Zero public domain dedication. + +import math +import typing + +import more_itertools +from hypothesis import given +from hypothesis import strategies as st + +import pygeoif.functions +import pygeoif.types + + +@given( + coords=st.one_of( + st.lists( + st.tuples( + st.floats( + allow_subnormal=False, + ), + st.floats(allow_subnormal=False), + ), + ), + st.lists( + st.tuples( + st.floats(allow_subnormal=False), + st.floats(allow_subnormal=False), + st.floats(allow_subnormal=False), + ), + ), + ), +) +def test_fuzz_centroid( + coords: typing.Union[ + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + ], +) -> None: + center, area = pygeoif.functions.centroid(coords=coords) + if area == 0 or math.isnan(area): + assert math.isnan(center[0]) + assert math.isnan(center[1]) + else: # pragma: no cover + assert isinstance(center[0], float) + assert isinstance(center[1], float) + assert len(center) == 2 + + +@given( + coords=st.one_of( + st.floats(), + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + st.lists( + st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + ), + ), + st.lists( + st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + st.lists( + st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + ), + ), + st.tuples(st.floats(), st.floats()), + st.tuples(st.floats(), st.floats(), st.floats()), + ), + ), + st.tuples(st.floats(), st.floats()), + st.tuples(st.floats(), st.floats(), st.floats()), + ), + other=st.one_of( + st.floats(), + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + st.lists( + st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + ), + ), + st.lists( + st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + st.lists( + st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + ), + ), + st.tuples(st.floats(), st.floats()), + st.tuples(st.floats(), st.floats(), st.floats()), + ), + ), + st.tuples(st.floats(), st.floats()), + st.tuples(st.floats(), st.floats(), st.floats()), + ), +) +def test_fuzz_compare_coordinates( + coords: typing.Union[ + float, + typing.Tuple[float, float], + typing.Tuple[float, float, float], + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + typing.Sequence[ + typing.Union[ + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + ] + ], + typing.Sequence[ + typing.Union[ + typing.Tuple[float, float], + typing.Tuple[float, float, float], + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + typing.Sequence[ + typing.Union[ + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + ] + ], + ] + ], + ], + other: typing.Union[ + float, + typing.Tuple[float, float], + typing.Tuple[float, float, float], + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + typing.Sequence[ + typing.Union[ + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + ] + ], + typing.Sequence[ + typing.Union[ + typing.Tuple[float, float], + typing.Tuple[float, float, float], + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + typing.Sequence[ + typing.Union[ + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + ] + ], + ] + ], + ], +) -> None: + assert isinstance( + pygeoif.functions.compare_coordinates(coords=coords, other=other), + bool, + ) + + flat_coords = ( + [coords] if isinstance(coords, float) else more_itertools.collapse(coords) + ) + flat_other = [other] if isinstance(other, float) else more_itertools.collapse(other) + + if any(math.isnan(c) for c in flat_coords): # pragma: no cover + assert not pygeoif.functions.compare_coordinates(coords=coords, other=coords) + else: # pragma: no cover + assert pygeoif.functions.compare_coordinates(coords=coords, other=coords) + if any(math.isnan(c) for c in flat_other): # pragma: no cover + assert not pygeoif.functions.compare_coordinates(coords=other, other=other) + else: # pragma: no cover + assert pygeoif.functions.compare_coordinates(coords=other, other=other) + + +@given( + first=st.from_type(pygeoif.types.GeoInterface), + second=st.from_type(pygeoif.types.GeoInterface), +) +def test_fuzz_compare_geo_interface( + first: pygeoif.types.GeoInterface, + second: pygeoif.types.GeoInterface, +) -> None: + assert isinstance( + pygeoif.functions.compare_geo_interface(first=first, second=second), + bool, + ) + + +@given( + points=st.lists( + st.tuples( + st.floats( + allow_nan=False, + allow_infinity=False, + allow_subnormal=False, + width=32, + ), + st.floats( + allow_nan=False, + allow_infinity=False, + allow_subnormal=False, + width=32, + ), + ), + ), +) +def test_fuzz_convex_hull(points: typing.List[typing.Tuple[float, float]]) -> None: + hull = pygeoif.functions.convex_hull(points=points) + + for coord in hull: + assert coord in points + assert len(hull) <= len(points) + 1 + assert pygeoif.functions.signed_area(hull) >= 0 + + +@given( + coords=st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + ), +) +def test_fuzz_dedupe( + coords: typing.Union[ + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + ], +) -> None: + deduped = pygeoif.functions.dedupe(coords=coords) + + assert len(deduped) <= len(coords) + for coord in deduped: + assert coord in coords + + +@given( + coordinate=st.one_of( + st.tuples(st.floats(), st.floats()), + st.tuples(st.floats(), st.floats(), st.floats()), + ), + move_by=st.one_of( + st.tuples(st.floats(), st.floats()), + st.tuples(st.floats(), st.floats(), st.floats()), + ), +) +def test_fuzz_move_coordinate( + coordinate: typing.Union[ + typing.Tuple[float, float], + typing.Tuple[float, float, float], + ], + move_by: typing.Union[ + typing.Tuple[float, float], + typing.Tuple[float, float, float], + ], +) -> None: + moved = pygeoif.functions.move_coordinate(coordinate=coordinate, move_by=move_by) + + assert len(moved) == len(move_by) + + +@given( + coordinates=st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + st.lists( + st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + ), + ), + st.tuples(st.floats(), st.floats()), + st.tuples(st.floats(), st.floats(), st.floats()), + ), + move_by=st.one_of( + st.tuples(st.floats(), st.floats()), + st.tuples(st.floats(), st.floats(), st.floats()), + ), +) +def test_fuzz_move_coordinates( + coordinates: typing.Union[ + typing.Tuple[float, float], + typing.Tuple[float, float, float], + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + typing.Sequence[ + typing.Union[ + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + ] + ], + ], + move_by: typing.Union[ + typing.Tuple[float, float], + typing.Tuple[float, float, float], + ], +) -> None: + moved = pygeoif.functions.move_coordinates(coordinates=coordinates, move_by=move_by) + + assert moved if coordinates else not moved + + +@given( + coords=st.one_of( + st.lists(st.tuples(st.floats(), st.floats())), + st.lists(st.tuples(st.floats(), st.floats(), st.floats())), + ), +) +def test_fuzz_signed_area( + coords: typing.Union[ + typing.Sequence[typing.Tuple[float, float]], + typing.Sequence[typing.Tuple[float, float, float]], + ], +) -> None: + assert isinstance(pygeoif.functions.signed_area(coords=coords), float) diff --git a/tests/test_factories.py b/tests/test_factories.py index cf2a437..1dbafe9 100644 --- a/tests/test_factories.py +++ b/tests/test_factories.py @@ -89,7 +89,7 @@ def test_force_2d_polygon() -> None: internal = [(0.5, 0.5), (0.5, 1.5), (1.5, 1.5), (1.5, 0.5), (0.5, 0.5)] p = geometry.Polygon(external, [internal]) p2d = factories.force_2d(p) - assert p2d.coords[0] == (((0, 0), (0, 2), (2, 2), (2, 0), (0, 0))) + assert p2d.coords[0] == ((0, 0), (0, 2), (2, 2), (2, 0), (0, 0)) assert p2d.coords[1] == ( ((0.5, 0.5), (0.5, 1.5), (1.5, 1.5), (1.5, 0.5), (0.5, 0.5)), ) @@ -107,7 +107,7 @@ def test_force_2d_polygon() -> None: p = geometry.Polygon(external, [internal]) p2d = factories.force_2d(p) - assert p2d.coords[0] == (((0, 0), (0, 2), (2, 2), (2, 0), (0, 0))) + assert p2d.coords[0] == ((0, 0), (0, 2), (2, 2), (2, 0), (0, 0)) assert p2d.coords[1] == ( ((0.5, 0.5), (0.5, 1.5), (1.5, 1.5), (1.5, 0.5), (0.5, 0.5)), ) @@ -261,8 +261,10 @@ class TestWKT: "POINT M (1 1 80)", "LINESTRING(3 4,10 50,20 25)", "LINESTRING (30 10, 10 30, 40 40)", - "MULTIPOLYGON (((10 10, 10 20, 20 20, 20 15, 10 10))," - "((60 60, 70 70, 80 60, 60 60 )))", + ( + "MULTIPOLYGON (((10 10, 10 20, 20 20, 20 15, 10 10))," + "((60 60, 70 70, 80 60, 60 60 )))" + ), """MULTIPOLYGON (((40 40, 20 45, 45 30, 40 40)), ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20)))""", @@ -385,8 +387,8 @@ def test_multilinestring(self) -> None: ) assert isinstance(p, geometry.MultiLineString) - assert next(iter(p.geoms)).coords == (((3, 4), (10, 50), (20, 25))) - assert list(p.geoms)[1].coords == (((-5, -8), (-10, -8), (-15, -4))) + assert next(iter(p.geoms)).coords == ((3, 4), (10, 50), (20, 25)) + assert list(p.geoms)[1].coords == ((-5, -8), (-10, -8), (-15, -4)) assert ( p.wkt == "MULTILINESTRING ((3 4, 10 50, " "20 25),(-5 -8, " diff --git a/tests/test_functions.py b/tests/test_functions.py index b56f83f..13ddf64 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -72,8 +72,7 @@ def test_signed_area2() -> None: def test_centroid_line() -> None: a0 = [(0, 0), (1, 1), (0, 0)] - with pytest.raises(ZeroDivisionError): - assert centroid(a0) + assert centroid(a0) == ((math.nan, math.nan), 0) def test_signed_area_0_3d() -> None: diff --git a/tests/test_line.py b/tests/test_line.py index 0665a43..777c90e 100644 --- a/tests/test_line.py +++ b/tests/test_line.py @@ -1,5 +1,6 @@ """Test LineString.""" +import math from unittest import mock import pytest @@ -20,6 +21,12 @@ def test_coords_get_3d() -> None: assert line.coords == ((0.0, 0.0, 0), (1.0, 1.0, 1)) +def test_coords_get_nan() -> None: + line = geometry.LineString([(0, math.nan, 0), (1, 1, math.nan), (2, 2, 2)]) + + assert line.coords == ((2, 2, 2),) + + def test_empty_points_omitted() -> None: line = geometry.LineString([(0, 0, 0), (None, None, None), (2, 2, 2)]) @@ -213,3 +220,21 @@ def test_empty_bounds() -> None: line = geometry.LineString([]) assert line.bounds == () + + +def test_bounds_1_pt() -> None: + line = geometry.LineString([(0, 0)]) + + assert line.bounds == (0, 0, 0, 0) + + +def test_empty_coords() -> None: + line = geometry.LineString([]) + + assert line.coords == () + + +def test_empty_coords_nan() -> None: + line = geometry.LineString(((math.nan, math.nan),)) + + assert line.coords == () diff --git a/tests/test_multipoint.py b/tests/test_multipoint.py index 04c3705..3cdde6a 100644 --- a/tests/test_multipoint.py +++ b/tests/test_multipoint.py @@ -1,5 +1,7 @@ """Test MultiPoint.""" +import math + import pytest from pygeoif import geometry @@ -156,12 +158,18 @@ def test_empty() -> None: def test_repr_empty() -> None: - multipoint = geometry.MultiPoint([(None, None)]) + multipoint = geometry.MultiPoint([(math.nan, math.nan)]) - assert repr(multipoint) == "MultiPoint(((),))" + assert repr(multipoint) == "MultiPoint(())" def test_empty_bounds() -> None: multipoint = geometry.MultiPoint([(None, None)]) assert multipoint.bounds == () + + +def test_empty_geoms() -> None: + multipoint = geometry.MultiPoint([(math.nan, math.nan)]) + + assert not list(multipoint.geoms) diff --git a/tests/test_point.py b/tests/test_point.py index 9d91682..eacb587 100644 --- a/tests/test_point.py +++ b/tests/test_point.py @@ -82,6 +82,12 @@ def test_repr_empty() -> None: assert repr(point) == "Point()" +def test_coords_empty() -> None: + point = geometry.Point(None, None) + + assert point.coords == () + + def test_repr2d() -> None: point = geometry.Point(1, 0) diff --git a/tests/test_polygon.py b/tests/test_polygon.py index 8b17c1c..b2c9294 100644 --- a/tests/test_polygon.py +++ b/tests/test_polygon.py @@ -62,6 +62,18 @@ def test_from_dict_shell_only() -> None: } +def test_from_dict_empty_coordinates() -> None: + polygon = geometry.Polygon._from_dict( + { + "type": "Polygon", + "bbox": (0.0, 0.0, 1.0, 1.0), + "coordinates": (), + }, + ) + + assert polygon.is_empty + + def test_from_dict_with_holes() -> None: polygon = geometry.Polygon._from_dict( {