Skip to content

Commit

Permalink
REGR: fix __eq__ to not ignore z values (#1829)
Browse files Browse the repository at this point in the history
  • Loading branch information
jorisvandenbossche committed Jul 22, 2023
1 parent bee5531 commit 70915c9
Show file tree
Hide file tree
Showing 8 changed files with 320 additions and 36 deletions.
5 changes: 5 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ Improvements:
to allow, skip, or error on nonfinite (NaN / Inf) coordinates. The default
behaviour (allow) is backwards compatible (#1594).

2.0.2 (unreleased)
------------------

Bug fixes:

- Fix regression in the (in)equality comparison (``geom1 == geom2``) using ``__eq__`` to
not ignore the z-coordinates (#1732).
- Fix ``MultiPolygon()`` constructor to accept polygons without holes (#1850).


Expand Down
7 changes: 7 additions & 0 deletions docs/release/2.x.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,15 @@ Improvements:
to allow, skip, or error on nonfinite (NaN / Inf) coordinates. The default
behaviour (allow) is backwards compatible (#1594).

.. _version-2-0-2:

Version 2.0.2 (unreleased)
--------------------------

Bug fixes:

- Fix regression in the (in)equality comparison (``geom1 == geom2``) using ``__eq__`` to
not ignore the z-coordinates (#1732).
- Fix ``MultiPolygon()`` constructor to accept polygons without holes (#1850).


Expand Down
29 changes: 29 additions & 0 deletions shapely/geometry/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,23 @@ def __sub__(self, other):
def __xor__(self, other):
return self.symmetric_difference(other)

def __eq__(self, other):
if not isinstance(other, BaseGeometry):
return NotImplemented
# equal_nan=False is the default, but not yet available for older numpy
# TODO updated once we require numpy >= 1.19
return type(other) == type(self) and np.array_equal(
self.coords, other.coords # , equal_nan=False
)

def __ne__(self, other):
if not isinstance(other, BaseGeometry):
return NotImplemented
return not self.__eq__(other)

def __hash__(self):
return super().__hash__()

# Coordinate access
# -----------------

Expand Down Expand Up @@ -917,6 +934,18 @@ def geoms(self):
def __bool__(self):
return self.is_empty is False

def __eq__(self, other):
if not isinstance(other, BaseGeometry):
return NotImplemented
return (
type(other) == type(self)
and len(self.geoms) == len(other.geoms)
and all(a == b for a, b in zip(self.geoms, other.geoms))
)

def __hash__(self):
return super().__hash__()

def svg(self, scale_factor=1.0, color=None):
"""Returns a group of SVG elements for the multipart geometry.
Expand Down
29 changes: 29 additions & 0 deletions shapely/geometry/polygon.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,35 @@ def coords(self):
"Component rings have coordinate sequences, but the polygon does not"
)

def __eq__(self, other):
if not isinstance(other, BaseGeometry):
return NotImplemented
if not isinstance(other, Polygon):
return False
check_empty = (self.is_empty, other.is_empty)
if all(check_empty):
return True
elif any(check_empty):
return False
my_coords = [self.exterior.coords] + [
interior.coords for interior in self.interiors
]
other_coords = [other.exterior.coords] + [
interior.coords for interior in other.interiors
]
if not len(my_coords) == len(other_coords):
return False
# equal_nan=False is the default, but not yet available for older numpy
return np.all(
[
np.array_equal(left, right) # , equal_nan=False)
for left, right in zip(my_coords, other_coords)
]
)

def __hash__(self):
return super().__hash__()

@property
def __geo_interface__(self):
if self.exterior == LinearRing():
Expand Down
13 changes: 13 additions & 0 deletions shapely/tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,19 @@
empty,
)

all_types_z = (
point_z,
line_string_z,
polygon_z,
multi_point_z,
multi_line_string_z,
multi_polygon_z,
polygon_with_hole_z,
geometry_collection_z,
empty_point_z,
empty_line_string_z,
)


@contextmanager
def ignore_invalid(condition=True):
Expand Down
237 changes: 237 additions & 0 deletions shapely/tests/geometry/test_equality.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import numpy as np
import pytest

import shapely
from shapely import LinearRing, LineString, MultiLineString, Point, Polygon
from shapely.tests.common import all_types, all_types_z, ignore_invalid


@pytest.mark.parametrize("geom", all_types + all_types_z)
def test_equality(geom):
assert geom == geom
transformed = shapely.transform(geom, lambda x: x, include_z=True)
assert geom == transformed
assert not (geom != transformed)


@pytest.mark.parametrize(
"left, right",
[
# (slightly) different coordinate values
(LineString([(0, 0), (1, 1)]), LineString([(0, 0), (1, 2)])),
(LineString([(0, 0), (1, 1)]), LineString([(0, 0), (1, 1 + 1e-12)])),
# different coordinate order
(LineString([(0, 0), (1, 1)]), LineString([(1, 1), (0, 0)])),
# different number of coordinates (but spatially equal)
(LineString([(0, 0), (1, 1)]), LineString([(0, 0), (1, 1), (1, 1)])),
(LineString([(0, 0), (1, 1)]), LineString([(0, 0), (0.5, 0.5), (1, 1)])),
# different order of sub-geometries
(
MultiLineString([[(1, 1), (2, 2)], [(2, 2), (3, 3)]]),
MultiLineString([[(2, 2), (3, 3)], [(1, 1), (2, 2)]]),
),
],
)
def test_equality_false(left, right):
assert left != right


with ignore_invalid():
cases1 = [
(LineString([(0, 1), (2, np.nan)]), LineString([(0, 1), (2, np.nan)])),
(
LineString([(0, 1), (np.nan, np.nan)]),
LineString([(0, 1), (np.nan, np.nan)]),
),
(LineString([(np.nan, 1), (2, 3)]), LineString([(np.nan, 1), (2, 3)])),
(LineString([(0, np.nan), (2, 3)]), LineString([(0, np.nan), (2, 3)])),
(
LineString([(np.nan, np.nan), (np.nan, np.nan)]),
LineString([(np.nan, np.nan), (np.nan, np.nan)]),
),
# NaN as explicit Z coordinate
# TODO: if first z is NaN -> considered as 2D -> tested below explicitly
# (
# LineString([(0, 1, np.nan), (2, 3, np.nan)]),
# LineString([(0, 1, np.nan), (2, 3, np.nan)]),
# ),
(
LineString([(0, 1, 2), (2, 3, np.nan)]),
LineString([(0, 1, 2), (2, 3, np.nan)]),
),
# (
# LineString([(0, 1, np.nan), (2, 3, 4)]),
# LineString([(0, 1, np.nan), (2, 3, 4)]),
# ),
]


@pytest.mark.parametrize("left, right", cases1)
def test_equality_with_nan(left, right):
# TODO currently those evaluate as not equal, but we are considering to change this
# assert left == right
assert not (left == right)
# assert not (left != right)
assert left != right


with ignore_invalid():
cases2 = [
(
LineString([(0, 1, np.nan), (2, 3, np.nan)]),
LineString([(0, 1, np.nan), (2, 3, np.nan)]),
),
(
LineString([(0, 1, np.nan), (2, 3, 4)]),
LineString([(0, 1, np.nan), (2, 3, 4)]),
),
]


@pytest.mark.parametrize("left, right", cases2)
def test_equality_with_nan_z(left, right):
# TODO: those are currently considered equal because z dimension is ignored
if shapely.geos_version < (3, 12, 0):
assert left == right
assert not (left != right)
else:
# on GEOS main z dimension is not ignored -> NaNs cause inequality
assert left != right


with ignore_invalid():
cases3 = [
(LineString([(0, np.nan), (2, 3)]), LineString([(0, 1), (2, 3)])),
(LineString([(0, 1), (2, np.nan)]), LineString([(0, 1), (2, 3)])),
(LineString([(0, 1, np.nan), (2, 3, 4)]), LineString([(0, 1, 2), (2, 3, 4)])),
(LineString([(0, 1, 2), (2, 3, np.nan)]), LineString([(0, 1, 2), (2, 3, 4)])),
]


@pytest.mark.parametrize("left, right", cases3)
def test_equality_with_nan_false(left, right):
assert left != right


def test_equality_with_nan_z_false():
with ignore_invalid():
left = LineString([(0, 1, np.nan), (2, 3, np.nan)])
right = LineString([(0, 1, np.nan), (2, 3, 4)])

if shapely.geos_version < (3, 10, 0):
# GEOS <= 3.9 fill the NaN with 0, so the z dimension is different
# assert left != right
# however, has_z still returns False, so z dimension is ignored in .coords
assert left == right
elif shapely.geos_version < (3, 12, 0):
# GEOS 3.10-3.11 ignore NaN for Z also when explicitly created with 3D
# and so the geometries are considered as 2D (and thus z dimension is ignored)
assert left == right
else:
assert left != right


def test_equality_z():
# different dimensionality
geom1 = Point(0, 1)
geom2 = Point(0, 1, 0)
assert geom1 != geom2

# different dimensionality with NaN z
geom2 = Point(0, 1, np.nan)
if shapely.geos_version < (3, 10, 0):
# GEOS < 3.8 fill the NaN with 0, so the z dimension is different
# assert geom1 != geom2
# however, has_z still returns False, so z dimension is ignored in .coords
assert geom1 == geom2
elif shapely.geos_version < (3, 12, 0):
# GEOS 3.10-3.11 ignore NaN for Z also when explicitly created with 3D
# and so the geometries are considered as 2D (and thus z dimension is ignored)
assert geom1 == geom2
else:
assert geom1 != geom2


def test_equality_exact_type():
# geometries with different type but same coord seq are not equal
geom1 = LineString([(0, 0), (1, 1), (0, 1), (0, 0)])
geom2 = LinearRing([(0, 0), (1, 1), (0, 1), (0, 0)])
geom3 = Polygon([(0, 0), (1, 1), (0, 1), (0, 0)])
assert geom1 != geom2
assert geom1 != geom3
assert geom2 != geom3

# empty with different type
geom1 = shapely.from_wkt("POINT EMPTY")
geom2 = shapely.from_wkt("LINESTRING EMPTY")
assert geom1 != geom2


def test_equality_polygon():
# different exterior rings
geom1 = shapely.from_wkt("POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))")
geom2 = shapely.from_wkt("POLYGON ((0 0, 10 0, 10 10, 0 15, 0 0))")
assert geom1 != geom2

# different number of holes
geom1 = shapely.from_wkt(
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (1 1, 2 1, 2 2, 1 1))"
)
geom2 = shapely.from_wkt(
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (1 1, 2 1, 2 2, 1 1), (3 3, 4 3, 4 4, 3 3))"
)
assert geom1 != geom2

# different order of holes
geom1 = shapely.from_wkt(
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (3 3, 4 3, 4 4, 3 3), (1 1, 2 1, 2 2, 1 1))"
)
geom2 = shapely.from_wkt(
"POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0), (1 1, 2 1, 2 2, 1 1), (3 3, 4 3, 4 4, 3 3))"
)
assert geom1 != geom2


@pytest.mark.parametrize("geom", all_types)
def test_comparison_notimplemented(geom):
# comparing to a non-geometry class should return NotImplemented in __eq__
# to ensure proper delegation to other (eg to ensure comparison of scalar
# with array works)
# https://github.com/shapely/shapely/issues/1056
assert geom.__eq__(1) is NotImplemented

# with array
arr = np.array([geom, geom], dtype=object)

result = arr == geom
assert isinstance(result, np.ndarray)
assert result.all()

result = geom == arr
assert isinstance(result, np.ndarray)
assert result.all()

result = arr != geom
assert isinstance(result, np.ndarray)
assert not result.any()

result = geom != arr
assert isinstance(result, np.ndarray)
assert not result.any()


def test_comparison_not_supported():
geom1 = Point(1, 1)
geom2 = Point(2, 2)

with pytest.raises(TypeError, match="not supported between instances"):
geom1 > geom2

with pytest.raises(TypeError, match="not supported between instances"):
geom1 < geom2

with pytest.raises(TypeError, match="not supported between instances"):
geom1 >= geom2

with pytest.raises(TypeError, match="not supported between instances"):
geom1 <= geom2

0 comments on commit 70915c9

Please sign in to comment.