From 5ab65bbcb19733725e660ff00dce32fece22c5f7 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sat, 28 Oct 2023 14:06:15 +0100 Subject: [PATCH 1/4] first iteration to make geometries immutable --- pygeoif/geometry.py | 100 +++++++++++++++++++++++--------------- tests/test_linear_ring.py | 24 --------- 2 files changed, 61 insertions(+), 63 deletions(-) diff --git a/pygeoif/geometry.py b/pygeoif/geometry.py index 6ea62edd..be96a039 100644 --- a/pygeoif/geometry.py +++ b/pygeoif/geometry.py @@ -20,6 +20,7 @@ import math import warnings from itertools import chain +from typing import Any from typing import Iterable from typing import Iterator from typing import NoReturn @@ -49,6 +50,18 @@ class _Geometry: """Base Class for geometry objects.""" + __slots__ = ("_geoms",) + + def __setattr__(self, *args: Any) -> NoReturn: + raise AttributeError( + f"Attributes of {self.__class__.__name__} cannot be changed", + ) + + def __delattr__(self, *args: Any) -> NoReturn: + raise AttributeError( + f"Attributes of {self.__class__.__name__} cannot be deleted", + ) + def __str__(self) -> str: return self.wkt @@ -226,18 +239,22 @@ def __init__(self, x: float, y: float, z: Optional[float] = None) -> None: 2 or 3 coordinate parameters: x, y, [z] : float Easting, northing, and elevation. """ - self._coordinates = cast( - PointType, - tuple( - coordinate - for coordinate in (x, y, z) - if coordinate is not None and not math.isnan(coordinate) + object.__setattr__( + self, + "_geoms", + cast( + PointType, + tuple( + coordinate + for coordinate in (x, y, z) + if coordinate is not None and not math.isnan(coordinate) + ), ), ) def __repr__(self) -> str: """Return the representation.""" - return f"{self.geom_type}{self._coordinates}" + return f"{self.geom_type}{self._geoms}" @property def is_empty(self) -> bool: @@ -246,50 +263,50 @@ def is_empty(self) -> bool: A Point is considered empty when it has less than 2 coordinates. """ - return len(self._coordinates) < 2 # noqa: PLR2004 + return len(self._geoms) < 2 # noqa: PLR2004 @property def x(self) -> float: """Return x coordinate.""" - return self._coordinates[0] + return self._geoms[0] @property def y(self) -> float: """Return y coordinate.""" - return self._coordinates[1] + return self._geoms[1] @property def z(self) -> Optional[float]: """Return z coordinate.""" if self.has_z: - return self._coordinates[2] # type: ignore [misc] + return self._geoms[2] # type: ignore [misc] msg = f"The {self!r} geometry does not have z values" raise DimensionError(msg) @property def coords(self) -> Tuple[PointType]: """Return the geometry coordinates.""" - return (self._coordinates,) + return (self._geoms,) @property def has_z(self) -> bool: """Return True if the geometry's coordinate sequence(s) have z values.""" - return len(self._coordinates) == 3 # noqa: PLR2004 + return len(self._geoms) == 3 # noqa: PLR2004 @property def _wkt_coords(self) -> str: - return " ".join(str(coordinate) for coordinate in self._coordinates) + return " ".join(str(coordinate) for coordinate in self._geoms) @property def _wkt_inset(self) -> str: """Return Z for 3 dimensional geometry or an empty string for 2 dimensions.""" - return " Z " if len(self._coordinates) == 3 else " " # noqa: PLR2004 + return " Z " if len(self._geoms) == 3 else " " # noqa: PLR2004 @property def __geo_interface__(self) -> GeoInterface: """Return the geo interface.""" geo_interface = super().__geo_interface__ - geo_interface["coordinates"] = cast(PointType, tuple(self._coordinates)) + geo_interface["coordinates"] = cast(PointType, tuple(self._geoms)) return geo_interface @classmethod @@ -337,7 +354,7 @@ def __init__(self, coordinates: LineType) -> None: >>> a = LineString([(0, 0), (1, 0), (1, 1)]) """ - self._geoms = self._set_geoms(coordinates) + object.__setattr__(self, "_geoms", self._set_geoms(coordinates)) def __repr__(self) -> str: """Return the representation.""" @@ -422,7 +439,7 @@ def _set_geoms(coordinates: LineType) -> Tuple[Point, ...]: ) last_len = len(coord) point = Point(*coord) - if not point.is_empty: + if point: geoms.append(point) return tuple(geoms) @@ -461,7 +478,7 @@ def __init__(self, coordinates: LineType) -> None: """ super().__init__(coordinates) if not self.is_empty and self._geoms[0].coords != self._geoms[-1].coords: - self._geoms = (*self._geoms, self._geoms[0]) + object.__setattr__(self, "_geoms", (*self._geoms, self._geoms[0])) @property def centroid(self) -> Optional[Point]: @@ -544,10 +561,11 @@ def __init__( >>> coords = ((0., 0.), (0., 1.), (1., 1.), (1., 0.), (0., 0.)) >>> polygon = Polygon(coords) """ - self._interiors: Tuple[LinearRing, ...] = () + interiors: Tuple[LinearRing, ...] = () if holes: - self._interiors = tuple(LinearRing(hole) for hole in holes) - self._exterior = LinearRing(shell) + interiors = tuple(LinearRing(hole) for hole in holes) + exterior = LinearRing(shell) + object.__setattr__(self, "_geoms", (exterior, interiors)) def __repr__(self) -> str: """Return the representation.""" @@ -556,12 +574,12 @@ def __repr__(self) -> str: @property def exterior(self) -> LinearRing: """Return the exterior Linear Ring of the polygon.""" - return self._exterior + return self._geoms[0] @property def interiors(self) -> Iterator[LinearRing]: """Interiors (Holes) of the polygon.""" - yield from (interior for interior in self._interiors if interior) + yield from (interior for interior in self._geoms[1] if interior) @property def is_empty(self) -> bool: @@ -570,7 +588,7 @@ def is_empty(self) -> bool: A polygon is empty when it does not have an exterior. """ - return self._exterior.is_empty + return self._geoms[0].is_empty @property def coords(self) -> PolygonType: @@ -579,7 +597,7 @@ def coords(self) -> PolygonType: Note that this is not implemented in Shapely. """ - if self._interiors: + if self._geoms[1]: return self.exterior.coords, tuple( interior.coords for interior in self.interiors if interior ) @@ -588,7 +606,7 @@ def coords(self) -> PolygonType: @property def has_z(self) -> Optional[bool]: """Return True if the geometry's coordinate sequence(s) have z values.""" - return self._exterior.has_z + return self._geoms[0].has_z @property def maybe_valid(self) -> bool: @@ -602,7 +620,7 @@ def maybe_valid(self) -> bool: return False return ( all(interior.maybe_valid for interior in self.interiors) - if self._exterior.maybe_valid + if self.exterior.maybe_valid else False ) @@ -759,7 +777,7 @@ def __init__(self, points: Sequence[PointType], unique: bool = False) -> None: """ if unique: points = set(points) # type: ignore [assignment] - self._geoms = tuple(Point(*point) for point in points) + object.__setattr__(self, "_geoms", tuple(Point(*point) for point in points)) def __len__(self) -> int: """Return the number of points in this MultiPoint.""" @@ -831,7 +849,7 @@ def __init__(self, lines: Sequence[LineType], unique: bool = False) -> None: """ if unique: lines = {tuple(line) for line in lines} # type: ignore [assignment] - self._geoms = tuple(LineString(line) for line in lines) + object.__setattr__(self, "_geoms", tuple(LineString(line) for line in lines)) def __len__(self) -> int: """Return the number of lines in the collection.""" @@ -926,14 +944,18 @@ def __init__(self, polygons: Sequence[PolygonType], unique: bool = False) -> Non if unique: polygons = set(polygons) # type: ignore [assignment] - self._geoms = tuple( - Polygon( - shell=polygon[0], - holes=polygon[1] # type: ignore [misc] - if len(polygon) == 2 # noqa: PLR2004 - else None, - ) - for polygon in polygons + object.__setattr__( + self, + "_geoms", + tuple( + Polygon( + shell=polygon[0], + holes=polygon[1] # type: ignore [misc] + if len(polygon) == 2 # noqa: PLR2004 + else None, + ) + for polygon in polygons + ), ) def __len__(self) -> int: @@ -1036,7 +1058,7 @@ def __init__( ---- geometries (Iterable[Geometry] """ - self._geoms = tuple(geom for geom in geometries if geom) + object.__setattr__(self, "_geoms", tuple(geom for geom in geometries if geom)) def __eq__(self, other: object) -> bool: """ diff --git a/tests/test_linear_ring.py b/tests/test_linear_ring.py index 529d705c..8e4d3c9b 100644 --- a/tests/test_linear_ring.py +++ b/tests/test_linear_ring.py @@ -230,30 +230,6 @@ def test_centroid_valid() -> None: assert line.centroid == geometry.Point(2, 1) -def test_centroid_invalid() -> None: - ring = geometry.LinearRing([(0, 0), (2, 0), (2, 2), (0, 2)]) - line = geometry.LineString( - [ - (28, 16), - (37, 31), - (21, 50), - (-21, 64), - (-84, 64), - (-148, 46), - (-95, 10), - (-72, 46), - (-40, 64), - (-9, 64), - (12, 50), - (20, 31), - (15, 16), - ], - ) - ring._geoms = line._geoms - - assert ring.centroid is None - - def test_empty() -> None: ring = geometry.LinearRing([]) From 80513bdb438ce27dea22ace2ae9f74cd6b6779c9 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sat, 28 Oct 2023 14:36:30 +0100 Subject: [PATCH 2/4] Make geometries hashable --- pygeoif/geometry.py | 42 ++++++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/pygeoif/geometry.py b/pygeoif/geometry.py index be96a039..60fda545 100644 --- a/pygeoif/geometry.py +++ b/pygeoif/geometry.py @@ -21,6 +21,7 @@ import warnings from itertools import chain from typing import Any +from typing import Hashable from typing import Iterable from typing import Iterator from typing import NoReturn @@ -52,16 +53,23 @@ class _Geometry: __slots__ = ("_geoms",) - def __setattr__(self, *args: Any) -> NoReturn: + _geoms: Hashable + + def __setattr__(self, *args: Any) -> NoReturn: # noqa: ANN401 + msg = f"Attributes of {self.__class__.__name__} cannot be changed" raise AttributeError( - f"Attributes of {self.__class__.__name__} cannot be changed", + msg, ) - def __delattr__(self, *args: Any) -> NoReturn: + def __delattr__(self, *args: Any) -> NoReturn: # noqa: ANN401 + msg = f"Attributes of {self.__class__.__name__} cannot be deleted" raise AttributeError( - f"Attributes of {self.__class__.__name__} cannot be deleted", + msg, ) + def __hash__(self) -> int: + return hash(self._geoms) + def __str__(self) -> str: return self.wkt @@ -230,6 +238,8 @@ class Point(_Geometry): 1.0 """ + _geoms: PointType + def __init__(self, x: float, y: float, z: Optional[float] = None) -> None: """ Initialize a Point. @@ -339,6 +349,8 @@ class LineString(_Geometry): A sequence of Points """ + _geoms: Tuple[Point, ...] + def __init__(self, coordinates: LineType) -> None: """ Initialize a Linestring. @@ -538,6 +550,8 @@ class Polygon(_Geometry): A sequence of rings which bound all existing holes. """ + _geoms: Tuple[LinearRing, ...] + def __init__( self, shell: LineType, @@ -579,7 +593,11 @@ def exterior(self) -> LinearRing: @property def interiors(self) -> Iterator[LinearRing]: """Interiors (Holes) of the polygon.""" - yield from (interior for interior in self._geoms[1] if interior) + yield from ( + interior + for interior in self._geoms[1] # type: ignore [attr-defined] + if interior + ) @property def is_empty(self) -> bool: @@ -710,11 +728,7 @@ def coords(self) -> NoReturn: @property def has_z(self) -> Optional[bool]: """Return True if any geometry of the collection have z values.""" - return ( - any(geom.has_z for geom in self.geoms) - if self._geoms # type: ignore [attr-defined] - else None - ) + return any(geom.has_z for geom in self.geoms) if self._geoms else None @property def geoms(self) -> Iterator[_Geometry]: @@ -753,6 +767,8 @@ class MultiPoint(_MultiGeometry): A sequence of Points """ + _geoms: Tuple[Point, ...] + def __init__(self, points: Sequence[PointType], unique: bool = False) -> None: """ Create a collection of one or more points. @@ -829,6 +845,8 @@ class MultiLineString(_MultiGeometry): A sequence of LineStrings """ + _geoms: Tuple[LineString, ...] + def __init__(self, lines: Sequence[LineType], unique: bool = False) -> None: """ Initialize the MultiLineString. @@ -911,6 +929,8 @@ class MultiPolygon(_MultiGeometry): A sequence of `Polygon` instances """ + _geoms: Tuple[Polygon, ...] + def __init__(self, polygons: Sequence[PolygonType], unique: bool = False) -> None: """ Initialize a Multipolygon. @@ -1047,6 +1067,8 @@ class isn't generally supported by ordinary GIS sw (viewers and so on). So {'type': 'Point', 'coordinates': (1.0, -1.0)}]} """ + _geoms: Tuple[Union[Geometry, "GeometryCollection"], ...] + def __init__( self, geometries: Iterable[Union[Geometry, "GeometryCollection"]], From 26769f57b6e229a8008baf59a6aff20167aa3802 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sat, 28 Oct 2023 15:53:12 +0100 Subject: [PATCH 3/4] add tests for hash, get and set attribute --- tests/test_base.py | 74 ++++++++++++++++++++++++++++++++ tests/test_geometrycollection.py | 28 ++++++++++++ tests/test_line.py | 6 +++ tests/test_linear_ring.py | 6 +++ tests/test_multiline.py | 8 ++++ tests/test_multipoint.py | 6 +++ tests/test_multipolygon.py | 38 ++++++++++++++++ tests/test_point.py | 12 ++++++ tests/test_polygon.py | 6 +++ 9 files changed, 184 insertions(+) diff --git a/tests/test_base.py b/tests/test_base.py index 615a56e0..0585f40e 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -72,3 +72,77 @@ def test_get_bounds() -> None: base_geo = geometry._Geometry() with pytest.raises(NotImplementedError, match="^Must be implemented by subclass$"): assert base_geo._get_bounds() + + +@pytest.mark.parametrize( + ("attr_val", "expected_error", "expected_error_message"), + [ + # Happy path tests + ( + ("attribute", "value"), + AttributeError, + "Attributes of _Geometry cannot be changed", + ), + ( + ("another_attribute", 123), + AttributeError, + "Attributes of _Geometry cannot be changed", + ), + ( + ("yet_another_attribute", [1, 2, 3]), + AttributeError, + "Attributes of _Geometry cannot be changed", + ), + # Edge cases + (("", "value"), AttributeError, "Attributes of _Geometry cannot be changed"), + ((None, "value"), TypeError, "attribute name must be string, not 'NoneType'"), + # Error cases + ((123, "value"), TypeError, "attribute name must be string, not 'int'"), + (([1, 2, 3], "value"), TypeError, "attribute name must be string, not 'list'"), + ], +) +def test_setattr(attr_val, expected_error, expected_error_message) -> None: + base_geo = geometry._Geometry() + + with pytest.raises(expected_error, match=f"^{expected_error_message}$"): + setattr(base_geo, *attr_val) + + +@pytest.mark.parametrize( + ("attr", "expected_error", "expected_error_message"), + [ + ( + "attr1", + AttributeError, + "Attributes of _Geometry cannot be deleted", + ), # realistic test value + ( + "", + AttributeError, + "Attributes of _Geometry cannot be deleted", + ), # edge case: empty string + ( + None, + TypeError, + "attribute name must be string, not 'NoneType'", + ), # edge case: None + ( + 123, + TypeError, + "attribute name must be string, not 'int'", + ), # error case: non-string attribute + ], + ids=[ + "realistic_test_value", + "edge_case_empty_string", + "edge_case_None", + "error_case_non_string_attribute", + ], +) +def test_delattr(attr, expected_error, expected_error_message) -> None: + # Arrange + base_geo = geometry._Geometry() + + # Act + with pytest.raises(expected_error, match=f"^{expected_error_message}$"): + delattr(base_geo, attr) diff --git a/tests/test_geometrycollection.py b/tests/test_geometrycollection.py index 79696c9b..a0bccc78 100644 --- a/tests/test_geometrycollection.py +++ b/tests/test_geometrycollection.py @@ -1,5 +1,7 @@ """Test Baseclass.""" +import pytest + from pygeoif import geometry @@ -425,3 +427,29 @@ def test_nested_geometry_collection_repr_eval() -> None: ).__geo_interface__ == gc.__geo_interface__ ) + + +@pytest.mark.xfail(reason="GeometryCollection is not hashable") +def test_nested_geometry_collection_hash() -> None: + multipoint = geometry.MultiPoint([(0, 0), (1, 1), (1, 2), (2, 2)]) + gc1 = geometry.GeometryCollection([geometry.Point(0, 0), multipoint]) + line1 = geometry.LineString([(0, 0), (3, 1)]) + gc2 = geometry.GeometryCollection([gc1, line1]) + poly1 = geometry.Polygon([(0, 0), (1, 1), (1, 0), (0, 0)]) + e = [(0, 0), (0, 2), (2, 2), (2, 0), (0, 0)] + i = [(1, 0), (0.5, 0.5), (1, 1), (1.5, 0.5), (1, 0)] + poly2 = geometry.Polygon(e, [i]) + p0 = geometry.Point(0, 0) + p1 = geometry.Point(-1, -1) + ring = geometry.LinearRing([(0, 0), (1, 1), (1, 0), (0, 0)]) + line = geometry.LineString([(0, 0), (1, 1)]) + gc = geometry.GeometryCollection([gc2, poly1, poly2, p0, p1, ring, line]) + + assert hash(gc) == 0 + + +@pytest.mark.xfail(reason="GeometryCollection is not hashable") +def test_hash_empty() -> None: + gc = geometry.GeometryCollection([]) + + assert hash(gc) == hash(geometry.GeometryCollection([])) diff --git a/tests/test_line.py b/tests/test_line.py index b0b4df8a..2d6fd5bf 100644 --- a/tests/test_line.py +++ b/tests/test_line.py @@ -19,6 +19,12 @@ def test_coords_get_3d() -> None: assert line.coords == ((0.0, 0.0, 0), (1.0, 1.0, 1)) +def test_hash() -> None: + line = geometry.LineString([(0, 0, 0), (1, 1, 1)]) + + assert hash(line) == hash(geometry.LineString([(0, 0, 0), (1, 1, 1)])) + + def test_set_geoms_raises() -> None: line = geometry.LineString([(0, 0), (1, 0)]) # pragma: no mutate diff --git a/tests/test_linear_ring.py b/tests/test_linear_ring.py index 8e4d3c9b..3a67a879 100644 --- a/tests/test_linear_ring.py +++ b/tests/test_linear_ring.py @@ -240,3 +240,9 @@ def test_empty_bounds() -> None: ring = geometry.LinearRing([]) assert ring.bounds == () + + +def test_hash() -> None: + ring = geometry.LinearRing([(0, 0), (4, 0), (4, 2), (0, 2)]) + + assert hash(ring) == hash(((0, 0), (4, 0), (4, 2), (0, 2), (0, 0))) diff --git a/tests/test_multiline.py b/tests/test_multiline.py index 05a3b3cd..ff189e2b 100644 --- a/tests/test_multiline.py +++ b/tests/test_multiline.py @@ -200,3 +200,11 @@ def test_empty_bounds() -> None: lines = geometry.MultiLineString([]) assert lines.bounds == () + + +def test_hash() -> None: + lines = geometry.MultiLineString(([(0, 0), (1, 1)], [[0.0, 0.0], [1.0, 2.0]])) + + assert hash(lines) == hash( + geometry.MultiLineString(([(0, 0), (1, 1)], [[0.0, 0.0], [1.0, 2.0]])), + ) diff --git a/tests/test_multipoint.py b/tests/test_multipoint.py index d4af0030..ba9da565 100644 --- a/tests/test_multipoint.py +++ b/tests/test_multipoint.py @@ -148,6 +148,12 @@ def test_from_points_unique() -> None: ) +def test_hash() -> None: + multipoint = geometry.MultiPoint([(0, 0), (1, 0), (2, 2)]) + + assert hash(multipoint) == hash(geometry.MultiPoint([(0, 0), (1, 0), (2, 2)])) + + def test_empty() -> None: multipoint = geometry.MultiPoint([(1, None)]) diff --git a/tests/test_multipolygon.py b/tests/test_multipolygon.py index d4bb9f1e..bd8538ef 100644 --- a/tests/test_multipolygon.py +++ b/tests/test_multipolygon.py @@ -284,3 +284,41 @@ def test_empty_bounds() -> None: polys = geometry.MultiPolygon([]) assert polys.bounds == () + + +def test_hash() -> None: + polys = geometry.MultiPolygon( + ( + ( + ((0, 0), (0, 2), (2, 2), (2, 0), (0, 0)), + ( + ((1, 0), (0.5, 0.5), (1, 1), (1.5, 0.5), (1, 0)), + ((1, 0), (0.5, 0.5), (1, 1), (1.5, 0.5), (1, 0)), + ), + ), + (((0, 0, 0), (1, 1, 0), (1, 0, 0), (0, 0, 0)),), + ( + ((0, 0), (0, 2), (2, 2), (2, 0), (0, 0)), + (((1, 0), (0.5, 0.5), (1, 1), (1.5, 0.5), (1, 0)),), + ), + ), + ) + + assert hash(polys) == hash( + geometry.MultiPolygon( + ( + ( + ((0, 0), (0, 2), (2, 2), (2, 0), (0, 0)), + ( + ((1, 0), (0.5, 0.5), (1, 1), (1.5, 0.5), (1, 0)), + ((1, 0), (0.5, 0.5), (1, 1), (1.5, 0.5), (1, 0)), + ), + ), + (((0, 0, 0), (1, 1, 0), (1, 0, 0), (0, 0, 0)),), + ( + ((0, 0), (0, 2), (2, 2), (2, 0), (0, 0)), + (((1, 0), (0.5, 0.5), (1, 1), (1.5, 0.5), (1, 0)),), + ), + ), + ), + ) diff --git a/tests/test_point.py b/tests/test_point.py index 33a3fb93..37ad8803 100644 --- a/tests/test_point.py +++ b/tests/test_point.py @@ -57,6 +57,12 @@ def test_xy() -> None: assert point.y == 0 +def test_hash() -> None: + point = geometry.Point(1.0, 2.0, 3.0) + + assert hash(point) == hash((1.0, 2.0, 3.0)) + + def test_xy_raises_error_accessing_z() -> None: point = geometry.Point(1, 0) @@ -238,3 +244,9 @@ def test_empty_bounds() -> None: point = geometry.Point(None, None) assert point.bounds == () + + +def test_hash_empty() -> None: + point = geometry.Point(None, None) + + assert hash(point) == hash(()) diff --git a/tests/test_polygon.py b/tests/test_polygon.py index 4ef462d3..07c8b056 100644 --- a/tests/test_polygon.py +++ b/tests/test_polygon.py @@ -20,6 +20,12 @@ def test_coords_with_holes() -> None: ) +def test_hash() -> None: + polygon = geometry.Polygon([(0, 0), (1, 1), (1, 0), (0, 0)]) + + assert hash(polygon) == hash(geometry.Polygon([(0, 0), (1, 1), (1, 0), (0, 0)])) + + def test_geo_interface_shell_only() -> None: polygon = geometry.Polygon([(0, 0), (1, 1), (1, 0), (0, 0)]) From e5e9e0469b18599ca5d288d760e6c77136356a78 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sat, 28 Oct 2023 16:09:29 +0100 Subject: [PATCH 4/4] catch pypy differing error messages --- tests/test_base.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_base.py b/tests/test_base.py index 0585f40e..862c7f79 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -95,10 +95,10 @@ def test_get_bounds() -> None: ), # Edge cases (("", "value"), AttributeError, "Attributes of _Geometry cannot be changed"), - ((None, "value"), TypeError, "attribute name must be string, not 'NoneType'"), + ((None, "value"), TypeError, ".*attribute name must be string.*"), # Error cases - ((123, "value"), TypeError, "attribute name must be string, not 'int'"), - (([1, 2, 3], "value"), TypeError, "attribute name must be string, not 'list'"), + ((123, "value"), TypeError, ".*attribute name must be string.*"), + (([1, 2, 3], "value"), TypeError, ".*attribute name must be string.*"), ], ) def test_setattr(attr_val, expected_error, expected_error_message) -> None: @@ -124,12 +124,12 @@ def test_setattr(attr_val, expected_error, expected_error_message) -> None: ( None, TypeError, - "attribute name must be string, not 'NoneType'", + ".*attribute name must be string.*", ), # edge case: None ( 123, TypeError, - "attribute name must be string, not 'int'", + ".*attribute name must be string.*", ), # error case: non-string attribute ], ids=[