From f7fbd1e87878ca871fbb1fd19eb8c2b25ebbc373 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Wed, 1 May 2024 23:18:25 +0100 Subject: [PATCH 1/8] WIP move point implementation to kml coordinates --- fastkml/geometry.py | 92 +++++++++++++++++---------------------------- 1 file changed, 35 insertions(+), 57 deletions(-) diff --git a/fastkml/geometry.py b/fastkml/geometry.py index 8b164fde..d05afa8f 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -46,6 +46,8 @@ from fastkml.helpers import enum_subelement from fastkml.helpers import subelement_bool_kwarg from fastkml.helpers import subelement_enum_kwarg +from fastkml.helpers import xml_subelement +from fastkml.helpers import xml_subelement_kwarg from fastkml.registry import RegistryItem from fastkml.registry import known_types from fastkml.registry import registry @@ -122,13 +124,13 @@ def coordinates_subelement( """ if getattr(obj, attr_name, None): p = precision if precision is not None else 6 - coordinates = getattr(obj, attr_name) - if len(coordinates[0]) == 2: - tuples = (f"{c[0]:.{p}f},{c[1]:.{p}f}" for c in coordinates) - elif len(coordinates[0]) == 3: - tuples = (f"{c[0]:.{p}f},{c[1]:.{p}f},{c[2]:.{p}f}" for c in coordinates) + coords = getattr(obj, attr_name) + if len(coords[0]) == 2: + tuples = (f"{c[0]:.{p}f},{c[1]:.{p}f}" for c in coords) + elif len(coords[0]) == 3: + tuples = (f"{c[0]:.{p}f},{c[1]:.{p}f},{c[2]:.{p}f}" for c in coords) else: - msg = f"Invalid dimensions in coordinates '{coordinates}'" + msg = f"Invalid dimensions in coordinates '{coords}'" raise KMLWriteError(msg) element.text = " ".join(tuples) @@ -192,6 +194,17 @@ def __init__( super().__init__(ns=ns, name_spaces=name_spaces, **kwargs) self.coords = coords or [] + def __repr__(self) -> str: + """Create a string (c)representation for Coordinates.""" + return ( + f"{self.__class__.__module__}.{self.__class__.__name__}(" + f"ns={self.ns!r}, " + f"name_spaces={self.name_spaces!r}, " + f"coords={self.coords!r}, " + f"**kwargs={self._get_splat()!r}," + ")" + ) + def __bool__(self) -> bool: return bool(self.coords) @@ -292,7 +305,7 @@ def __bool__(self) -> bool: def geometry(self) -> Optional[AnyGeometryType]: return self._geometry - def _etree_coordinates( + def xx_etree_coordinates( self, coordinates: Union[Sequence[Point2D], Sequence[Point3D]], precision: Optional[int], @@ -367,7 +380,7 @@ def _get_geometry( return None @classmethod - def _get_kwargs( + def xx_get_kwargs( cls, *, ns: str, @@ -453,7 +466,7 @@ def __init__( extrude=extrude, tessellate=tessellate, altitude_mode=altitude_mode, - geometry=geometry, + # geometry=geometry, **kwargs, ) @@ -477,57 +490,22 @@ def __bool__(self) -> bool: return bool(self.kml_coordinates) @property - def __geometry(self) -> Optional[geo.Point]: + def geometry(self) -> Optional[geo.Point]: if not self.kml_coordinates: return None return geo.Point.from_coordinates(self.kml_coordinates.coords) - def etree_element( - self, - precision: Optional[int] = None, - verbosity: Verbosity = Verbosity.normal, - ) -> Element: - element = super().etree_element(precision=precision, verbosity=verbosity) - assert isinstance(self.geometry, geo.Point) - coords = self.geometry.coords - element.append( - self._etree_coordinates( - coords, # type: ignore[arg-type] - precision=precision, - ), - ) - return element - @classmethod - def _get_geometry( - cls, - *, - ns: str, - element: Element, - strict: bool, - ) -> Optional[geo.Point]: - coords = cls._get_coordinates(ns=ns, element=element, strict=strict) - try: - return geo.Point.from_coordinates(coords) - except (IndexError, TypeError) as e: - handle_invalid_geometry_error( - error=e, - element=element, - strict=strict, - ) - return None - - -# registry.register( -# Point, -# item=RegistryItem( -# classes=(Coordinates,), -# attr_name="kml_coordinates", -# node_name="coordinates", -# get_kwarg=subelement_coordinates_kwarg, -# set_element=coordinates_subelement, -# ), -# ) +registry.register( + Point, + item=RegistryItem( + classes=(Coordinates,), + attr_name="kml_coordinates", + node_name="coordinates", + get_kwarg=xml_subelement_kwarg, + set_element=xml_subelement, + ), +) class LineString(_Geometry): @@ -579,8 +557,8 @@ def etree_element( ) -> Element: element = super().etree_element(precision=precision, verbosity=verbosity) assert isinstance(self.geometry, geo.LineString) - coords = self.geometry.coords - element.append(self._etree_coordinates(coords, precision=precision)) + # coords = self.geometry.coords + # element.append(self._etree_coordinates(coords, precision=precision)) return element @classmethod From bc8cbc8f4f2fb04118ca9e5b1ddbfd739d86763b Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Wed, 1 May 2024 23:42:29 +0100 Subject: [PATCH 2/8] WIP move linestring and linearring implementation to kml coordinates --- fastkml/geometry.py | 70 ++++++++++++++++------------- tests/geometries/linearring_test.py | 4 +- tests/geometries/linestring_test.py | 4 +- 3 files changed, 42 insertions(+), 36 deletions(-) diff --git a/fastkml/geometry.py b/fastkml/geometry.py index d05afa8f..83ef567a 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -453,11 +453,12 @@ def __init__( if geometry is not None and kml_coordinates is not None: raise ValueError("geometry and kml_coordinates are mutually exclusive") if kml_coordinates is None: - self.kml_coordinates = ( + kml_coordinates = ( Coordinates(coords=geometry.coords) # type: ignore[arg-type] if geometry else None ) + self.kml_coordinates = kml_coordinates super().__init__( ns=ns, id=id, @@ -519,9 +520,15 @@ def __init__( extrude: Optional[bool] = None, tessellate: Optional[bool] = None, altitude_mode: Optional[AltitudeMode] = None, - geometry: geo.LineString, + geometry: Optional[geo.LineString] = None, + kml_coordinates: Optional[Coordinates] = None, **kwargs: Any, ) -> None: + if geometry is not None and kml_coordinates is not None: + raise ValueError("geometry and kml_coordinates are mutually exclusive") + if kml_coordinates is None: + kml_coordinates = Coordinates(coords=geometry.coords) if geometry else None + self.kml_coordinates = kml_coordinates super().__init__( ns=ns, name_spaces=name_spaces, @@ -530,7 +537,7 @@ def __init__( extrude=extrude, tessellate=tessellate, altitude_mode=altitude_mode, - geometry=geometry, + # geometry=geometry, **kwargs, ) @@ -550,35 +557,26 @@ def __repr__(self) -> str: ")" ) - def etree_element( - self, - precision: Optional[int] = None, - verbosity: Verbosity = Verbosity.normal, - ) -> Element: - element = super().etree_element(precision=precision, verbosity=verbosity) - assert isinstance(self.geometry, geo.LineString) - # coords = self.geometry.coords - # element.append(self._etree_coordinates(coords, precision=precision)) - return element + def __bool__(self) -> bool: + return bool(self.kml_coordinates) - @classmethod - def _get_geometry( - cls, - *, - ns: str, - element: Element, - strict: bool, - ) -> Optional[geo.LineString]: - coords = cls._get_coordinates(ns=ns, element=element, strict=strict) - try: - return geo.LineString.from_coordinates(coords) - except (IndexError, TypeError, DimensionError) as e: - handle_invalid_geometry_error( - error=e, - element=element, - strict=strict, - ) - return None + @property + def geometry(self) -> Optional[geo.LineString]: + if not self.kml_coordinates: + return None + return geo.LineString.from_coordinates(self.kml_coordinates.coords) + + +registry.register( + LineString, + item=RegistryItem( + classes=(Coordinates,), + attr_name="kml_coordinates", + node_name="coordinates", + get_kwarg=xml_subelement_kwarg, + set_element=xml_subelement, + ), +) class LinearRing(LineString): @@ -592,7 +590,8 @@ def __init__( extrude: Optional[bool] = None, tessellate: Optional[bool] = None, altitude_mode: Optional[AltitudeMode] = None, - geometry: geo.LinearRing, + geometry: Optional[geo.LinearRing] = None, + kml_coordinates: Optional[Coordinates] = None, **kwargs: Any, ) -> None: super().__init__( @@ -604,6 +603,7 @@ def __init__( tessellate=tessellate, altitude_mode=altitude_mode, geometry=geometry, + kml_coordinates=kml_coordinates, **kwargs, ) @@ -623,6 +623,12 @@ def __repr__(self) -> str: ")" ) + @property + def geometry(self) -> Optional[geo.LineString]: + if not self.kml_coordinates: + return None + return geo.LinearRing.from_coordinates(self.kml_coordinates.coords) + @classmethod def _get_geometry( cls, diff --git a/tests/geometries/linearring_test.py b/tests/geometries/linearring_test.py index 95dd294c..bd3ff0a1 100644 --- a/tests/geometries/linearring_test.py +++ b/tests/geometries/linearring_test.py @@ -67,7 +67,7 @@ def test_empty_from_string(self) -> None: "", ) - assert linear_ring.geometry.is_empty + assert not linear_ring.geometry def test_no_coordinates_from_string(self) -> None: """Test the from_string method with an empty LinearRing.""" @@ -76,7 +76,7 @@ def test_no_coordinates_from_string(self) -> None: "", ) - assert linear_ring.geometry.is_empty + assert not linear_ring.geometry def test_from_string_invalid_coordinates_non_numerical(self) -> None: """Test the from_string method with non numerical coordinates.""" diff --git a/tests/geometries/linestring_test.py b/tests/geometries/linestring_test.py index 7179aa8f..bca0da01 100644 --- a/tests/geometries/linestring_test.py +++ b/tests/geometries/linestring_test.py @@ -104,7 +104,7 @@ def test_empty_from_string(self) -> None: "", ) - assert linestring.geometry.is_empty + assert not linestring.geometry def test_no_coordinates_from_string(self) -> None: """Test the from_string method with no coordinates.""" @@ -115,7 +115,7 @@ def test_no_coordinates_from_string(self) -> None: "", ) - assert linestring.geometry.is_empty + assert not linestring.geometry def test_from_string_invalid_coordinates_non_numerical(self) -> None: """Test the from_string method with invalid coordinates.""" From 41d6aa5d5e9dc328cd731581e37c570d7869493d Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sat, 4 May 2024 18:30:54 +0100 Subject: [PATCH 3/8] Refactor Polygon and Multigeometry #316 #301 --- fastkml/geometry.py | 350 +++++++++++++++---------- fastkml/helpers.py | 1 - tests/geometries/boundaries_test.py | 103 ++++++++ tests/geometries/multigeometry_test.py | 59 +++-- 4 files changed, 345 insertions(+), 168 deletions(-) create mode 100644 tests/geometries/boundaries_test.py diff --git a/fastkml/geometry.py b/fastkml/geometry.py index 83ef567a..0a5f8ff4 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -16,9 +16,9 @@ import logging import re -from functools import partial from typing import Any from typing import Dict +from typing import Iterable from typing import List from typing import Optional from typing import Sequence @@ -34,6 +34,7 @@ from pygeoif.types import LineType from pygeoif.types import Point2D from pygeoif.types import Point3D +from typing_extensions import Self from fastkml import config from fastkml.base import _BaseObject @@ -48,6 +49,8 @@ from fastkml.helpers import subelement_enum_kwarg from fastkml.helpers import xml_subelement from fastkml.helpers import xml_subelement_kwarg +from fastkml.helpers import xml_subelement_list +from fastkml.helpers import xml_subelement_list_kwarg from fastkml.registry import RegistryItem from fastkml.registry import known_types from fastkml.registry import registry @@ -433,7 +436,6 @@ def xx_get_kwargs( class Point(_Geometry): - kml_coordinates: Optional[Coordinates] def __init__( @@ -537,7 +539,6 @@ def __init__( extrude=extrude, tessellate=tessellate, altitude_mode=altitude_mode, - # geometry=geometry, **kwargs, ) @@ -624,10 +625,13 @@ def __repr__(self) -> str: ) @property - def geometry(self) -> Optional[geo.LineString]: + def geometry(self) -> Optional[geo.LinearRing]: if not self.kml_coordinates: return None - return geo.LinearRing.from_coordinates(self.kml_coordinates.coords) + return cast( + geo.LinearRing, + geo.LinearRing.from_coordinates(self.kml_coordinates.coords), + ) @classmethod def _get_geometry( @@ -649,7 +653,122 @@ def _get_geometry( return None +class OuterBoundaryIs(_XMLObject): + _default_ns = config.KMLNS + kml_geometry: Optional[LinearRing] + + def __init__( + self, + *, + ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, + geometry: Optional[geo.LinearRing] = None, + kml_geometry: Optional[LinearRing] = None, + **kwargs: Any, + ) -> None: + if geometry is not None and kml_geometry is not None: + raise ValueError("geometry and kml_coordinates are mutually exclusive") + if kml_geometry is None: + kml_geometry = ( + LinearRing(ns=ns, name_spaces=name_spaces, geometry=geometry) + if geometry + else None + ) + self.kml_geometry = kml_geometry + super().__init__( + ns=ns, + name_spaces=name_spaces, + **kwargs, + ) + + def __bool__(self) -> bool: + return bool(self.kml_geometry) + + @classmethod + def get_tag_name(cls) -> str: + """Return the tag name.""" + return "outerBoundaryIs" + + @property + def geometry(self) -> Optional[geo.LinearRing]: + return self.kml_geometry.geometry if self.kml_geometry else None + + +registry.register( + OuterBoundaryIs, + item=RegistryItem( + classes=(LinearRing,), + attr_name="kml_geometry", + node_name="LinearRing", + get_kwarg=xml_subelement_kwarg, + set_element=xml_subelement, + ), +) + + +class InnerBoundaryIs(_XMLObject): + _default_ns = config.KMLNS + kml_geometries: List[LinearRing] + + def __init__( + self, + *, + ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, + geometries: Optional[Iterable[geo.LinearRing]] = None, + kml_geometries: Optional[Iterable[LinearRing]] = None, + **kwargs: Any, + ) -> None: + if geometries is not None and kml_geometries is not None: + raise ValueError("geometries and kml_coordinates are mutually exclusive") + if kml_geometries is None: + kml_geometries = ( + [ + LinearRing(ns=ns, name_spaces=name_spaces, geometry=lr) + for lr in geometries + ] + if geometries + else None + ) + self.kml_geometries = list(kml_geometries) if kml_geometries else [] + super().__init__( + ns=ns, + name_spaces=name_spaces, + **kwargs, + ) + + def __bool__(self) -> bool: + return bool(self.kml_geometries) + + @classmethod + def get_tag_name(cls) -> str: + """Return the tag name.""" + return "innerBoundaryIs" + + @property + def geometries(self) -> Optional[Iterable[geo.LinearRing]]: + if not self.kml_geometries: + return None + return [lr.geometry for lr in self.kml_geometries if lr.geometry] + + +registry.register( + InnerBoundaryIs, + item=RegistryItem( + classes=(LinearRing,), + attr_name="kml_geometries", + node_name="LinearRing", + get_kwarg=xml_subelement_list_kwarg, + set_element=xml_subelement_list, + ), +) + + class Polygon(_Geometry): + + outer_boundary_is: Optional[OuterBoundaryIs] + inner_boundary_is: Optional[InnerBoundaryIs] + def __init__( self, *, @@ -660,9 +779,18 @@ def __init__( extrude: Optional[bool] = None, tessellate: Optional[bool] = None, altitude_mode: Optional[AltitudeMode] = None, - geometry: geo.Polygon, + outer_boundary_is: Optional[OuterBoundaryIs] = None, + inner_boundary_is: Optional[InnerBoundaryIs] = None, + geometry: Optional[geo.Polygon] = None, **kwargs: Any, ) -> None: + if outer_boundary_is is not None and geometry is not None: + raise ValueError("outer_boundary_is and geometry are mutually exclusive") + if geometry is not None: + outer_boundary_is = OuterBoundaryIs(geometry=geometry.exterior) + inner_boundary_is = InnerBoundaryIs(geometries=geometry.interiors) + self.outer_boundary_is = outer_boundary_is + self.inner_boundary_is = inner_boundary_is super().__init__( ns=ns, name_spaces=name_spaces, @@ -671,10 +799,25 @@ def __init__( extrude=extrude, tessellate=tessellate, altitude_mode=altitude_mode, - geometry=geometry, **kwargs, ) + def __bool__(self) -> bool: + return bool(self.outer_boundary_is) + + @property + def geometry(self) -> Optional[geo.Polygon]: + if not self.outer_boundary_is: + return None + if self.inner_boundary_is is None: + return geo.Polygon.from_linear_rings( + cast(geo.LinearRing, self.outer_boundary_is.geometry), + ) + return geo.Polygon.from_linear_rings( # type: ignore[misc] + cast(geo.LinearRing, self.outer_boundary_is.geometry), + *self.inner_boundary_is.geometries, + ) + def __repr__(self) -> str: """Create a string (c)representation for Polygon.""" return ( @@ -691,95 +834,27 @@ def __repr__(self) -> str: ")" ) - def etree_element( - self, - precision: Optional[int] = None, - verbosity: Verbosity = Verbosity.normal, - ) -> Element: - element = super().etree_element(precision=precision, verbosity=verbosity) - if not self.geometry: - return element - assert isinstance(self.geometry, geo.Polygon) - linear_ring = partial(LinearRing, ns=self.ns, extrude=None, tessellate=None) - outer_boundary = cast( - Element, - config.etree.SubElement( # type: ignore[attr-defined] - element, - f"{self.ns}outerBoundaryIs", - ), - ) - outer_boundary.append( - linear_ring(geometry=self.geometry.exterior).etree_element( - precision=precision, - verbosity=verbosity, - ), - ) - for interior in self.geometry.interiors: - inner_boundary = cast( - Element, - config.etree.SubElement( # type: ignore[attr-defined] - element, - f"{self.ns}innerBoundaryIs", - ), - ) - inner_boundary.append( - linear_ring(geometry=interior).etree_element( - precision=precision, - verbosity=verbosity, - ), - ) - return element - @classmethod - def _get_geometry( - cls, - *, - ns: str, - element: Element, - strict: bool, - ) -> Optional[geo.Polygon]: - outer_boundary = element.find(f"{ns}outerBoundaryIs") - if outer_boundary is None: - error = config.etree.tostring( # type: ignore[attr-defined] - element, - encoding="UTF-8", - ).decode( - "UTF-8", - ) - msg = f"Missing outerBoundaryIs in {error}" - raise KMLParseError(msg) - outer_ring = outer_boundary.find(f"{ns}LinearRing") - if outer_ring is None: - error = config.etree.tostring( # type: ignore[attr-defined] - element, - encoding="UTF-8", - ).decode( - "UTF-8", - ) - msg = f"Missing LinearRing in {error}" - raise KMLParseError(msg) - exterior = LinearRing._get_geometry(ns=ns, element=outer_ring, strict=strict) - interiors = [] - for inner_boundary in element.findall(f"{ns}innerBoundaryIs"): - inner_ring = inner_boundary.find(f"{ns}LinearRing") - if inner_ring is None: - error = config.etree.tostring( # type: ignore[attr-defined] - element, - encoding="UTF-8", - ).decode( - "UTF-8", - ) - msg = f"Missing LinearRing in {error}" - raise KMLParseError(msg) - if hole := LinearRing._get_geometry( - ns=ns, - element=inner_ring, - strict=strict, - ): - interiors.append(hole) - if exterior: - return geo.Polygon.from_linear_rings(exterior, *interiors) - return None +registry.register( + Polygon, + item=RegistryItem( + classes=(OuterBoundaryIs,), + attr_name="outer_boundary_is", + node_name="outerBoundaryIs", + get_kwarg=xml_subelement_kwarg, + set_element=xml_subelement, + ), +) +registry.register( + Polygon, + item=RegistryItem( + classes=(InnerBoundaryIs,), + attr_name="inner_boundary_is", + node_name="innerBoundaryIs", + get_kwarg=xml_subelement_kwarg, + set_element=xml_subelement, + ), +) def create_multigeometry( @@ -829,6 +904,7 @@ class MultiGeometry(_Geometry): geo.MultiPolygon, geo.GeometryCollection, ) + kml_geometries: List[Union[Point, LineString, Polygon, LinearRing, Self]] def __init__( self, @@ -840,9 +916,27 @@ def __init__( extrude: Optional[bool] = None, tessellate: Optional[bool] = None, altitude_mode: Optional[AltitudeMode] = None, - geometry: MultiGeometryType, + kml_geometries: Optional[ + Iterable[Union[Point, LineString, Polygon, LinearRing, Self]] + ] = None, + geometry: Optional[MultiGeometryType] = None, **kwargs: Any, ) -> None: + if kml_geometries is not None and geometry is not None: + raise ValueError("kml_geometries and geometry are mutually exclusive") + if geometry is not None: + kml_geometries = [ + create_kml_geometry( # type: ignore[misc] + geometry=geom, + ns=ns, + name_spaces=name_spaces, + extrude=extrude, + tessellate=tessellate, + altitude_mode=altitude_mode, + ) + for geom in geometry.geoms + ] + self.kml_geometries = list(kml_geometries) if kml_geometries else [] super().__init__( ns=ns, name_spaces=name_spaces, @@ -851,10 +945,12 @@ def __init__( extrude=extrude, tessellate=tessellate, altitude_mode=altitude_mode, - geometry=geometry, **kwargs, ) + def __bool__(self) -> bool: + return bool(self.kml_geometries) + def __repr__(self) -> str: """Create a string (c)representation for MultiGeometry.""" return ( @@ -871,51 +967,23 @@ def __repr__(self) -> str: ")" ) - def etree_element( - self, - precision: Optional[int] = None, - verbosity: Verbosity = Verbosity.normal, - ) -> Element: - element = super().etree_element(precision=precision, verbosity=verbosity) - _map_to_kml = {mg: self.__class__ for mg in self.multi_geometries} - _map_to_kml.update(self.map_to_kml) - if self.geometry is None: - return element - assert isinstance(self.geometry, self.multi_geometries) - for geometry in self.geometry.geoms: - geometry_class = _map_to_kml[type(geometry)] - element.append( - geometry_class( - ns=self.ns, - name_spaces=self.name_spaces, - extrude=None, - tessellate=None, - altitude_mode=None, - geometry=geometry, # type: ignore[arg-type] - ).etree_element(precision=precision, verbosity=verbosity), - ) - return element + @property + def geometry(self) -> Optional[MultiGeometryType]: + return create_multigeometry( + [geom.geometry for geom in self.kml_geometries if geom.geometry], + ) - @classmethod - def _get_geometry( - cls, - *, - ns: str, - element: Element, - strict: bool, - ) -> Optional[MultiGeometryType]: - geometries = [] - allowed_geometries = (cls, *tuple(cls.map_to_kml.values())) - for g in allowed_geometries: - for e in element.findall(f"{ns}{g.__name__}"): - geometry = g._get_geometry( # type: ignore[attr-defined] - ns=ns, - element=e, - strict=strict, - ) - if geometry is not None: - geometries.append(geometry) - return create_multigeometry(geometries) + +registry.register( + MultiGeometry, + item=RegistryItem( + classes=(Point, LineString, Polygon, LinearRing, MultiGeometry), + attr_name="kml_geometries", + node_name="(Point|LineString|Polygon|LinearRing|MultiGeometry)", + get_kwarg=xml_subelement_list_kwarg, + set_element=xml_subelement_list, + ), +) def create_kml_geometry( diff --git a/fastkml/helpers.py b/fastkml/helpers.py index 34c1851c..83d93523 100644 --- a/fastkml/helpers.py +++ b/fastkml/helpers.py @@ -529,7 +529,6 @@ def attribute_float_kwarg( def _get_enum_value(enum_class: Type[Enum], text: str, strict: bool) -> Enum: - value = enum_class(text) if strict and value.value != text: msg = f"Value {text} is not a valid value for Enum {enum_class.__name__}" diff --git a/tests/geometries/boundaries_test.py b/tests/geometries/boundaries_test.py new file mode 100644 index 00000000..2736d99d --- /dev/null +++ b/tests/geometries/boundaries_test.py @@ -0,0 +1,103 @@ +# Copyright (C) 2023 - 2024 Christian Ledermann +# +# This library is free software; you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation; either version 2.1 of the License, or (at your option) +# any later version. +# +# This library is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Test the Outer and Inner Boundary classes.""" + +import pygeoif.geometry as geo + +from fastkml.geometry import Coordinates +from fastkml.geometry import InnerBoundaryIs +from fastkml.geometry import LinearRing +from fastkml.geometry import OuterBoundaryIs +from tests.base import Lxml +from tests.base import StdLibrary + + +class TestBoundaries(StdLibrary): + def test_outer_boundary(self) -> None: + """Test the init method.""" + coords = ((1, 2), (2, 0), (0, 0), (1, 2)) + outer_boundary = OuterBoundaryIs( + kml_geometry=LinearRing(kml_coordinates=Coordinates(coords=coords)), + ) + + assert outer_boundary.geometry == geo.LinearRing(coords) + assert outer_boundary.to_string(prettyprint=False).strip() == ( + '' + "" + "1.000000,2.000000 2.000000,0.000000 0.000000,0.000000 1.000000,2.000000" + "" + ) + + def test_read_outer_boundary(self) -> None: + """Test the from_string method.""" + outer_boundary = OuterBoundaryIs.class_from_string( + '' + "" + "1.0,4.0 2.0,0.0 0.0,0.0 1.0,4.0" + "" + "", + ) + + assert outer_boundary.geometry == geo.LinearRing( + ((1, 4), (2, 0), (0, 0), (1, 4)), + ) + + def test_inner_boundary(self) -> None: + """Test the init method.""" + coords = ((1, 2), (2, 0), (0, 0), (1, 2)) + inner_boundary = InnerBoundaryIs( + kml_geometries=[LinearRing(kml_coordinates=Coordinates(coords=coords))], + ) + + assert inner_boundary.geometries == [geo.LinearRing(coords)] + assert inner_boundary.to_string(prettyprint=False).strip() == ( + '' + "" + "1.000000,2.000000 2.000000,0.000000 0.000000,0.000000 1.000000,2.000000" + "" + ) + + def test_read_inner_boundary(self) -> None: + """Test the from_string method.""" + inner_boundary = InnerBoundaryIs.class_from_string( + '' + "" + "1.0,4.0 2.0,0.0 0.0,0.0 1.0,4.0" + "" + "" + "-122.366212,37.818977,30 -122.365424,37.819294,30 " + "-122.365704,37.819731,30 -122.366488,37.819402,30 -122.366212,37.818977,30" + "" + "", + ) + + assert inner_boundary.geometries == [ + geo.LinearRing(((1, 4), (2, 0), (0, 0), (1, 4))), + geo.LinearRing( + ( + (-122.366212, 37.818977, 30), + (-122.365424, 37.819294, 30), + (-122.365704, 37.819731, 30), + (-122.366488, 37.819402, 30), + (-122.366212, 37.818977, 30), + ), + ), + ] + + +class TestBoundariesLxml(Lxml, TestBoundaries): + pass diff --git a/tests/geometries/multigeometry_test.py b/tests/geometries/multigeometry_test.py index 67c2b2c8..20093e91 100644 --- a/tests/geometries/multigeometry_test.py +++ b/tests/geometries/multigeometry_test.py @@ -29,7 +29,7 @@ def test_1_point(self) -> None: """Test with one point.""" p = geo.MultiPoint([(1, 2)]) - mg = MultiGeometry(ns="", geometry=p) + mg = MultiGeometry(geometry=p) assert "coordinates>1.000000,2.000000" in mg.to_string() @@ -39,7 +39,7 @@ def test_2_points(self) -> None: """Test with two points.""" p = geo.MultiPoint([(1, 2), (3, 4)]) - mg = MultiGeometry(ns="", geometry=p) + mg = MultiGeometry(geometry=p) assert "coordinates>1.000000,2.0000003.000000,4.000000 None: def test_2_points_read(self) -> None: xml = ( - "00" - "1.000000,2.000000" - "3.000000,4.000000" + '' + "00" + "1.000000,2.000000" + "3.000000,4.000000" + "" ) - mg = MultiGeometry.class_from_string(xml, ns="") + mg = MultiGeometry.class_from_string(xml) assert mg.geometry == geo.MultiPoint([(1, 2), (3, 4)]) @@ -63,7 +65,7 @@ def test_1_linestring(self) -> None: """Test with one linestring.""" p = geo.MultiLineString([[(1, 2), (3, 4)]]) - mg = MultiGeometry(ns="", geometry=p) + mg = MultiGeometry(geometry=p) assert "coordinates>1.000000,2.000000 3.000000,4.000000" in mg.to_string() @@ -73,7 +75,7 @@ def test_2_linestrings(self) -> None: """Test with two linestrings.""" p = geo.MultiLineString([[(1, 2), (3, 4)], [(5, 6), (7, 8)]]) - mg = MultiGeometry(ns="", geometry=p) + mg = MultiGeometry(geometry=p) assert "coordinates>1.000000,2.000000 3.000000,4.0000005.000000,6.000000 7.000000,8.000000 None: def test_2_linestrings_read(self) -> None: xml = ( - "00" - "1.000000,2.000000 3.000000,4.000000" - "" - "5.000000,6.000000 7.000000,8.000000" - "" + '' + "00" + "1.000000,2.000000 3.000000,4.000000" + "" + "5.000000,6.000000 7.000000,8.000000" + "" ) mg = MultiGeometry.class_from_string(xml, ns="") @@ -97,7 +100,9 @@ def test_2_linestrings_read(self) -> None: class TestMultiPolygonStdLibrary(StdLibrary): def test_1_polygon(self) -> None: """Test with one polygon.""" - p = geo.MultiPolygon([[[[1, 2], [3, 4], [5, 6], [1, 2]]]]) + p = geo.MultiPolygon( + [(((1, 2), (3, 4), (5, 6), (1, 2)),)], + ) mg = MultiGeometry(ns="", geometry=p) @@ -120,15 +125,15 @@ def test_1_polygons_with_holes(self) -> None: ), ], ) - mg = MultiGeometry(ns="", geometry=p) + mg = MultiGeometry(geometry=p) assert ( "coordinates>0.000000,0.000000 0.000000,1.000000 1.000000,1.000000 " - "1.000000,0.000000 0.000000,0.0000000.250000,0.250000 0.250000,0.500000 0.500000,0.500000 " - "0.500000,0.250000 0.250000,0.250000" in mg.to_string() assert "Polygon>" in mg.to_string() @@ -147,19 +152,19 @@ def test_2_polygons(self) -> None: ], ) - mg = MultiGeometry(ns="", geometry=p) + mg = MultiGeometry(geometry=p) assert ( "coordinates>0.000000,0.000000 0.000000,1.000000 1.000000,1.000000 " - "1.000000,0.000000 0.000000,0.0000000.100000,0.100000 0.100000,0.200000 0.200000,0.200000 " - "0.200000,0.100000 0.100000,0.1000000.000000,0.000000 0.000000,2.000000 1.000000,1.000000 " - "1.000000,0.000000 0.000000,0.000000" in mg.to_string() assert "Polygon>" in mg.to_string() @@ -168,7 +173,8 @@ def test_2_polygons(self) -> None: def test_2_polygons_read(self) -> None: xml = ( - "00" + '' + "00" "" "0.000000,0.000000 0.000000,1.000000 1.000000,1.000000 " "1.000000,0.000000 0.000000,0.000000" @@ -183,7 +189,7 @@ def test_2_polygons_read(self) -> None: "" ) - mg = MultiGeometry.class_from_string(xml, ns="") + mg = MultiGeometry.class_from_string(xml) assert mg.geometry == geo.MultiPolygon( [ @@ -203,7 +209,7 @@ def test_1_point(self) -> None: """Test with one point.""" p = geo.GeometryCollection([geo.Point(1, 2)]) - mg = MultiGeometry(ns="", geometry=p) + mg = MultiGeometry(geometry=p) assert "coordinates>1.000000,2.000000" in mg.to_string() @@ -219,7 +225,7 @@ def test_geometries(self) -> None: ) gc = geo.GeometryCollection([p, ls, lr, poly]) - mg = MultiGeometry(ns="", geometry=gc) + mg = MultiGeometry(geometry=gc) assert "Point>" in mg.to_string() assert "LineString>" in mg.to_string() @@ -257,7 +263,8 @@ def test_multi_geometries(self) -> None: def test_multi_geometries_read(self) -> None: xml = ( - "00" + '' + "00" "1.000000,2.000000" "1.000000,2.000000 2.000000,0.000000" "0.000000,0.000000 0.000000,1.000000 " From 375aa5d90d1fe0057b0fa272117f940da86076a0 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sat, 4 May 2024 19:55:23 +0100 Subject: [PATCH 4/8] fix tests #160 #316 #301 --- fastkml/geometry.py | 156 ++++------------------------ fastkml/gx.py | 2 - tests/geometries/geometry_test.py | 16 +-- tests/geometries/linestring_test.py | 25 +++-- tests/geometries/point_test.py | 84 ++++++--------- tests/geometries/polygon_test.py | 15 ++- 6 files changed, 78 insertions(+), 220 deletions(-) diff --git a/fastkml/geometry.py b/fastkml/geometry.py index 0a5f8ff4..dbcc6eae 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -32,8 +32,6 @@ from pygeoif.types import GeoCollectionType from pygeoif.types import GeoType from pygeoif.types import LineType -from pygeoif.types import Point2D -from pygeoif.types import Point3D from typing_extensions import Self from fastkml import config @@ -245,7 +243,6 @@ class _Geometry(_BaseObject): extrude: Optional[bool] tessellate: Optional[bool] altitude_mode: Optional[AltitudeMode] - _geometry: Optional[AnyGeometryType] def __init__( self, @@ -257,7 +254,6 @@ def __init__( extrude: Optional[bool] = None, tessellate: Optional[bool] = None, altitude_mode: Optional[AltitudeMode] = None, - geometry: Optional[AnyGeometryType] = None, **kwargs: Any, ) -> None: """ @@ -283,7 +279,6 @@ def __init__( self.extrude = extrude self.tessellate = tessellate self.altitude_mode = altitude_mode - self._geometry = geometry def __repr__(self) -> str: """Create a string (c)representation for _Geometry.""" @@ -296,112 +291,10 @@ def __repr__(self) -> str: f"extrude={self.extrude!r}, " f"tessellate={self.tessellate!r}, " f"altitude_mode={self.altitude_mode}, " - f"geometry={self.geometry!r}, " f"**kwargs={self._get_splat()!r}," ")" ) - def __bool__(self) -> bool: - return bool(self._geometry) - - @property - def geometry(self) -> Optional[AnyGeometryType]: - return self._geometry - - def xx_etree_coordinates( - self, - coordinates: Union[Sequence[Point2D], Sequence[Point3D]], - precision: Optional[int], - ) -> Element: - element = cast( - Element, - config.etree.Element(f"{self.ns}coordinates"), # type: ignore[attr-defined] - ) - if not coordinates: - return element - p = precision if precision is not None else 6 - if len(coordinates[0]) == 2: - tuples = (f"{c[0]:.{p}f},{c[1]:.{p}f}" for c in coordinates) - elif len(coordinates[0]) == 3: - tuples = ( - f"{c[0]:.{p}f},{c[1]:.{p}f},{c[2]:.{p}f}" # type: ignore[misc] - for c in coordinates - ) - else: - msg = ( # type: ignore[unreachable] - f"Invalid dimensions in coordinates '{coordinates}'" - ) - raise KMLWriteError(msg) - element.text = " ".join(tuples) - return element - - @classmethod - def _get_coordinates( - cls, - *, - ns: str, - element: Element, - strict: bool, - ) -> Union[List[Point2D], List[Point3D]]: - """ - Get coordinates from element. - - Coordinates can be any number of tuples separated by a space (potentially any - number of whitespace characters). - Values in tuples should be separated by commas with no spaces. - - https://developers.google.com/kml/documentation/kmlreference#coordinates - """ - coordinates = element.find(f"{ns}coordinates") - if coordinates is not None: - # Clean up badly formatted tuples by stripping - # space following commas. - try: - latlons = re.sub(r", +", ",", coordinates.text.strip()).split() - except AttributeError: - return [] - try: - return [ # type: ignore[return-value] - tuple(float(c) for c in latlon.split(",")) for latlon in latlons - ] - except ValueError as error: - handle_invalid_geometry_error( - error=error, - element=element, - strict=strict, - ) - return [] - - @classmethod - def _get_geometry( - cls, - *, - ns: str, - element: Element, - strict: bool, - ) -> Optional[AnyGeometryType]: - return None - - @classmethod - def xx_get_kwargs( - cls, - *, - ns: str, - name_spaces: Optional[Dict[str, str]] = None, - element: Element, - strict: bool, - ) -> Dict[str, Any]: - kwargs = super()._get_kwargs( - ns=ns, - name_spaces=name_spaces, - element=element, - strict=strict, - ) - kwargs.update( - {"geometry": cls._get_geometry(ns=ns, element=element, strict=strict)}, - ) - return kwargs - registry.register( _Geometry, @@ -469,7 +362,6 @@ def __init__( extrude=extrude, tessellate=tessellate, altitude_mode=altitude_mode, - # geometry=geometry, **kwargs, ) @@ -490,13 +382,16 @@ def __repr__(self) -> str: ) def __bool__(self) -> bool: - return bool(self.kml_coordinates) + return bool(self.geometry) @property def geometry(self) -> Optional[geo.Point]: if not self.kml_coordinates: return None - return geo.Point.from_coordinates(self.kml_coordinates.coords) + try: + return geo.Point.from_coordinates(self.kml_coordinates.coords) + except (DimensionError, TypeError): + return None registry.register( @@ -559,13 +454,16 @@ def __repr__(self) -> str: ) def __bool__(self) -> bool: - return bool(self.kml_coordinates) + return bool(self.geometry) @property def geometry(self) -> Optional[geo.LineString]: if not self.kml_coordinates: return None - return geo.LineString.from_coordinates(self.kml_coordinates.coords) + try: + return geo.LineString.from_coordinates(self.kml_coordinates.coords) + except DimensionError: + return None registry.register( @@ -628,29 +526,13 @@ def __repr__(self) -> str: def geometry(self) -> Optional[geo.LinearRing]: if not self.kml_coordinates: return None - return cast( - geo.LinearRing, - geo.LinearRing.from_coordinates(self.kml_coordinates.coords), - ) - - @classmethod - def _get_geometry( - cls, - *, - ns: str, - element: Element, - strict: bool, - ) -> Optional[geo.LinearRing]: - coords = cls._get_coordinates(ns=ns, element=element, strict=strict) try: - return cast(geo.LinearRing, geo.LinearRing.from_coordinates(coords)) - except (IndexError, TypeError, DimensionError) as e: - handle_invalid_geometry_error( - error=e, - element=element, - strict=strict, + return cast( + geo.LinearRing, + geo.LinearRing.from_coordinates(self.kml_coordinates.coords), ) - return None + except DimensionError: + return None class OuterBoundaryIs(_XMLObject): @@ -682,7 +564,7 @@ def __init__( ) def __bool__(self) -> bool: - return bool(self.kml_geometry) + return bool(self.geometry) @classmethod def get_tag_name(cls) -> str: @@ -738,7 +620,7 @@ def __init__( ) def __bool__(self) -> bool: - return bool(self.kml_geometries) + return any(b.geometry for b in self.kml_geometries) @classmethod def get_tag_name(cls) -> str: @@ -809,7 +691,7 @@ def __bool__(self) -> bool: def geometry(self) -> Optional[geo.Polygon]: if not self.outer_boundary_is: return None - if self.inner_boundary_is is None: + if not self.inner_boundary_is: return geo.Polygon.from_linear_rings( cast(geo.LinearRing, self.outer_boundary_is.geometry), ) @@ -949,7 +831,7 @@ def __init__( ) def __bool__(self) -> bool: - return bool(self.kml_geometries) + return bool(self.geometry) def __repr__(self) -> str: """Create a string (c)representation for MultiGeometry.""" diff --git a/fastkml/gx.py b/fastkml/gx.py index 991193eb..b13508d1 100644 --- a/fastkml/gx.py +++ b/fastkml/gx.py @@ -225,7 +225,6 @@ def __init__( extrude=extrude, tessellate=tessellate, altitude_mode=altitude_mode, - geometry=None, **kwargs, ) @@ -388,7 +387,6 @@ def __init__( extrude=extrude, tessellate=tessellate, altitude_mode=altitude_mode, - geometry=None, **kwargs, ) diff --git a/tests/geometries/geometry_test.py b/tests/geometries/geometry_test.py index 2e42d6c6..62ebc58b 100644 --- a/tests/geometries/geometry_test.py +++ b/tests/geometries/geometry_test.py @@ -164,7 +164,7 @@ def test_multipoint(self) -> None: g = MultiGeometry.class_from_string(doc) - assert len(g.geometry) == 2 + assert len(g.geometry) == 2 # type: ignore[arg-type] def test_multilinestring(self) -> None: doc = """ @@ -180,7 +180,7 @@ def test_multilinestring(self) -> None: g = MultiGeometry.class_from_string(doc) - assert len(g.geometry) == 2 + assert len(g.geometry) == 2 # type: ignore[arg-type] def test_multipolygon(self) -> None: doc = """ @@ -212,7 +212,7 @@ def test_multipolygon(self) -> None: g = MultiGeometry.class_from_string(doc) - assert len(g.geometry) == 2 + assert len(g.geometry) == 2 # type: ignore[arg-type] def test_geometrycollection(self) -> None: doc = """ @@ -238,7 +238,7 @@ def test_geometrycollection(self) -> None: g = MultiGeometry.class_from_string(doc) - assert len(g.geometry) == 4 + assert len(g.geometry) == 4 # type: ignore[arg-type] def test_geometrycollection_with_linearring(self) -> None: doc = """ @@ -254,7 +254,7 @@ def test_geometrycollection_with_linearring(self) -> None: g = MultiGeometry.class_from_string(doc) - assert len(g.geometry) == 2 + assert len(g.geometry) == 2 # type: ignore[arg-type] assert g.geometry.geom_type == "GeometryCollection" @@ -544,9 +544,9 @@ def test_create_kml_geometry_geometrycollection(self) -> None: def test_create_kml_geometry_geometrycollection_roundtrip(self) -> None: multipoint = geo.MultiPoint([(0, 0), (1, 1), (1, 2), (2, 2)]) - gc1 = geo.GeometryCollection([multipoint, geo.Point(0, 0)]) + gc1 = geo.GeometryCollection([geo.Point(0, 0), multipoint]) line1 = geo.LineString([(0, 0), (3, 1)]) - gc2 = geo.GeometryCollection([gc1, line1]) + gc2 = geo.GeometryCollection([line1, gc1]) poly1 = geo.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)] @@ -557,13 +557,13 @@ def test_create_kml_geometry_geometrycollection_roundtrip(self) -> None: line = geo.LineString([(0, 0), (1, 1)]) gc = geo.GeometryCollection( [ - gc2, p0, p1, line, poly1, poly2, ring, + gc2, ], ) g = create_kml_geometry(gc) diff --git a/tests/geometries/linestring_test.py b/tests/geometries/linestring_test.py index bca0da01..379dae81 100644 --- a/tests/geometries/linestring_test.py +++ b/tests/geometries/linestring_test.py @@ -65,19 +65,18 @@ def test_from_string(self) -> None: ) def test_mixed_2d_3d_coordinates_from_string(self) -> None: - with pytest.raises( - KMLParseError, - match=r"^Invalid coordinates in", - ): - LineString.class_from_string( - '' - "1" - "1" - "" - "-122.364383,37.824664 -122.364152,37.824322,0" - "" - "", - ) + + linestring = LineString.class_from_string( + '' + "1" + "1" + "" + "-122.364383,37.824664 -122.364152,37.824322,0" + "" + "", + ) + + assert not linestring def test_mixed_2d_3d_coordinates_from_string_relaxed(self) -> None: line_string = LineString.class_from_string( diff --git a/tests/geometries/point_test.py b/tests/geometries/point_test.py index 042c92a8..138e7a09 100644 --- a/tests/geometries/point_test.py +++ b/tests/geometries/point_test.py @@ -20,7 +20,6 @@ import pytest from fastkml.exceptions import KMLParseError -from fastkml.exceptions import KMLWriteError from fastkml.geometry import Point from tests.base import Lxml from tests.base import StdLibrary @@ -98,11 +97,7 @@ def test_to_string_empty_geometry(self) -> None: """Test the to_string method.""" point = Point(geometry=geo.Point(None, None)) # type: ignore[arg-type] - with pytest.raises( - KMLWriteError, - match=r"Invalid dimensions in coordinates '\(\(\),\)'", - ): - point.to_string() + assert not point def test_from_string_2d(self) -> None: """Test the from_string method for a 2 dimensional point.""" @@ -161,14 +156,12 @@ def test_from_string_3d(self) -> None: def test_empty_from_string(self) -> None: """Test the from_string method.""" - with pytest.raises( - KMLParseError, - match=r"^Invalid coordinates in '", - ): - Point.class_from_string( - "", - ns="", - ) + point = Point.class_from_string( + "", + ns="", + ) + + assert not point def test_empty_from_string_relaxed(self) -> None: """Test that no error is raised when the geometry is empty and not strict.""" @@ -181,52 +174,39 @@ def test_empty_from_string_relaxed(self) -> None: assert point.geometry is None def test_from_string_empty_coordinates(self) -> None: - with pytest.raises( - KMLParseError, - match=r"^Invalid coordinates in '", - ): - Point.class_from_string( - "", - ns="", - ) + point = Point.class_from_string( + '', + ns="", + ) + + assert not point + assert point.geometry is None def test_from_string_invalid_coordinates(self) -> None: - with pytest.raises( - KMLParseError, - match=( - r"^Invalid coordinates in '" - "1" - ), - ): - Point.class_from_string( - "1", - ns="", - ) + + point = Point.class_from_string( + '' + "1", + ) + + assert not point def test_from_string_invalid_coordinates_4d(self) -> None: - with pytest.raises( - KMLParseError, - match=( - r"^Invalid coordinates in '" - "1,2,3,4" - ), - ): - Point.class_from_string( - "1,2,3,4", - ns="", - ) + + point = Point.class_from_string( + '' + "1,2,3,4", + ) + assert not point def test_from_string_invalid_coordinates_non_numerical(self) -> None: with pytest.raises( KMLParseError, - match=( - r"^Invalid coordinates in '" - "a,b,c" - ), + match=r"^Invalid coordinates in", ): Point.class_from_string( - "a,b,c", - ns="", + '' + "a,b,c", ) def test_from_string_invalid_coordinates_nan(self) -> None: @@ -235,8 +215,8 @@ def test_from_string_invalid_coordinates_nan(self) -> None: match=r"^Invalid coordinates in", ): Point.class_from_string( - "a,b", - ns="", + '' + "a,b", ) diff --git a/tests/geometries/polygon_test.py b/tests/geometries/polygon_test.py index c8ed0daa..08af7be6 100644 --- a/tests/geometries/polygon_test.py +++ b/tests/geometries/polygon_test.py @@ -17,9 +17,7 @@ """Test the geometry classes.""" import pygeoif.geometry as geo -import pytest -from fastkml.exceptions import KMLParseError from fastkml.geometry import Polygon from tests.base import Lxml from tests.base import StdLibrary @@ -89,8 +87,7 @@ def test_from_string_interiors_only(self) -> None: """ - with pytest.raises(KMLParseError, match=r"^Missing outerBoundaryIs in <"): - Polygon.class_from_string(doc) + assert not Polygon.class_from_string(doc) def test_from_string_exterior_wo_linearring(self) -> None: """Test exterior when no LinearRing in outer boundary.""" @@ -101,8 +98,7 @@ def test_from_string_exterior_wo_linearring(self) -> None: """ - with pytest.raises(KMLParseError, match=r"^Missing LinearRing in <"): - Polygon.class_from_string(doc) + assert not Polygon.class_from_string(doc) def test_from_string_interior_wo_linearring(self) -> None: """Test interior when no LinearRing in inner boundary.""" @@ -119,8 +115,11 @@ def test_from_string_interior_wo_linearring(self) -> None: """ - with pytest.raises(KMLParseError, match=r"^Missing LinearRing in <"): - Polygon.class_from_string(doc) + poly = Polygon.class_from_string(doc) + + assert poly.geometry == geo.Polygon( + ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)), + ) def test_from_string_exterior_interior(self) -> None: doc = """ From 2d80bc87836caae343fcc8b2262244c4c8cbb7d8 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sat, 11 May 2024 12:22:54 +0100 Subject: [PATCH 5/8] update tests, always specify the namespace --- fastkml/geometry.py | 129 +++++++++++-------------- tests/geometries/multigeometry_test.py | 9 +- tests/geometries/point_test.py | 1 - tests/geometries/polygon_test.py | 10 +- 4 files changed, 71 insertions(+), 78 deletions(-) diff --git a/fastkml/geometry.py b/fastkml/geometry.py index dbcc6eae..3ed4db7e 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -773,19 +773,69 @@ def create_multigeometry( return geo.GeometryCollection(geometries) -class MultiGeometry(_Geometry): - map_to_kml = { +def create_kml_geometry( + geometry: Union[GeoType, GeoCollectionType], + *, + ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, + id: Optional[str] = None, + target_id: Optional[str] = None, + extrude: Optional[bool] = None, + tessellate: Optional[bool] = None, + altitude_mode: Optional[AltitudeMode] = None, +) -> _Geometry: + """ + Create a KML geometry from a geometry object. + + Args: + ---- + geometry: Geometry object. + ns: Namespace of the object + id: Id of the object + target_id: Target id of the object + extrude: Specifies whether to connect the feature to the ground with a line. + tessellate: Specifies whether to allow the LineString to follow the terrain. + altitude_mode: Specifies how altitude components in the + element are interpreted. + + Returns: + ------- + KML geometry object. + + """ + _map_to_kml = { geo.Point: Point, - geo.LineString: LineString, geo.Polygon: Polygon, geo.LinearRing: LinearRing, + geo.LineString: LineString, + geo.MultiPoint: MultiGeometry, + geo.MultiLineString: MultiGeometry, + geo.MultiPolygon: MultiGeometry, + geo.GeometryCollection: MultiGeometry, } - multi_geometries = ( - geo.MultiPoint, - geo.MultiLineString, - geo.MultiPolygon, - geo.GeometryCollection, - ) + geom = shape(geometry) + for geometry_class, kml_class in _map_to_kml.items(): + if isinstance(geom, geometry_class): + return cast( + _Geometry, + kml_class( + ns=ns, + name_spaces=name_spaces, + id=id, + target_id=target_id, + extrude=extrude, + tessellate=tessellate, + altitude_mode=altitude_mode, + geometry=geom, + ), + ) + # this should be unreachable, but mypy doesn't know that + msg = f"Unsupported geometry type {type(geometry)}" # pragma: no cover + raise KMLWriteError(msg) # pragma: no cover + + +class MultiGeometry(_Geometry): + kml_geometries: List[Union[Point, LineString, Polygon, LinearRing, Self]] def __init__( @@ -866,64 +916,3 @@ def geometry(self) -> Optional[MultiGeometryType]: set_element=xml_subelement_list, ), ) - - -def create_kml_geometry( - geometry: Union[GeoType, GeoCollectionType], - *, - ns: Optional[str] = None, - name_spaces: Optional[Dict[str, str]] = None, - id: Optional[str] = None, - target_id: Optional[str] = None, - extrude: Optional[bool] = None, - tessellate: Optional[bool] = None, - altitude_mode: Optional[AltitudeMode] = None, -) -> _Geometry: - """ - Create a KML geometry from a geometry object. - - Args: - ---- - geometry: Geometry object. - ns: Namespace of the object - id: Id of the object - target_id: Target id of the object - extrude: Specifies whether to connect the feature to the ground with a line. - tessellate: Specifies whether to allow the LineString to follow the terrain. - altitude_mode: Specifies how altitude components in the - element are interpreted. - - Returns: - ------- - KML geometry object. - - """ - _map_to_kml = { - geo.Point: Point, - geo.Polygon: Polygon, - geo.LinearRing: LinearRing, - geo.LineString: LineString, - geo.MultiPoint: MultiGeometry, - geo.MultiLineString: MultiGeometry, - geo.MultiPolygon: MultiGeometry, - geo.GeometryCollection: MultiGeometry, - } - geom = shape(geometry) - for geometry_class, kml_class in _map_to_kml.items(): - if isinstance(geom, geometry_class): - return cast( - _Geometry, - kml_class( - ns=ns, - name_spaces=name_spaces, - id=id, - target_id=target_id, - extrude=extrude, - tessellate=tessellate, - altitude_mode=altitude_mode, - geometry=geom, - ), - ) - # this should be unreachable, but mypy doesn't know that - msg = f"Unsupported geometry type {type(geometry)}" # pragma: no cover - raise KMLWriteError(msg) # pragma: no cover diff --git a/tests/geometries/multigeometry_test.py b/tests/geometries/multigeometry_test.py index 20093e91..2ae32abe 100644 --- a/tests/geometries/multigeometry_test.py +++ b/tests/geometries/multigeometry_test.py @@ -292,7 +292,7 @@ def test_multi_geometries_read(self) -> None: "" ) - mg = MultiGeometry.class_from_string(xml, ns="") + mg = MultiGeometry.class_from_string(xml) assert mg.geometry == geo.GeometryCollection( ( @@ -360,15 +360,18 @@ def test_multi_geometries_read(self) -> None: def test_empty_multi_geometries_read(self) -> None: xml = ( - "00" + '' + "00" "" ) - mg = MultiGeometry.class_from_string(xml, ns="") + mg = MultiGeometry.class_from_string(xml) assert mg.geometry is None assert "MultiGeometry>" in mg.to_string() assert "coordinates>" not in mg.to_string() + assert mg.extrude is False + assert mg.tessellate is False class TestMultiPointLxml(Lxml, TestMultiPointStdLibrary): diff --git a/tests/geometries/point_test.py b/tests/geometries/point_test.py index 138e7a09..d0389647 100644 --- a/tests/geometries/point_test.py +++ b/tests/geometries/point_test.py @@ -176,7 +176,6 @@ def test_empty_from_string_relaxed(self) -> None: def test_from_string_empty_coordinates(self) -> None: point = Point.class_from_string( '', - ns="", ) assert not point diff --git a/tests/geometries/polygon_test.py b/tests/geometries/polygon_test.py index 08af7be6..cecb1cdc 100644 --- a/tests/geometries/polygon_test.py +++ b/tests/geometries/polygon_test.py @@ -171,14 +171,16 @@ def test_from_string_exterior_mixed_interior_relaxed(self) -> None: def test_empty_polygon(self) -> None: """Test empty polygon.""" doc = ( - "1" - "" + '1' + "" + "" ) - polygon = Polygon.class_from_string(doc, ns="") + polygon = Polygon.class_from_string(doc) assert not polygon.geometry - assert polygon.to_string() + assert polygon.outer_boundary_is is not None + assert "tessellate>1 Date: Sat, 11 May 2024 13:50:07 +0100 Subject: [PATCH 6/8] Update pygeoif dependency to >=1.5 and remove xfail for NaN handling test --- pyproject.toml | 2 +- tests/geometries/linestring_test.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e91fb4d9..bf1dc98a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ ] dependencies = [ "arrow", - "pygeoif>=1.4", + "pygeoif>=1.5", "typing-extensions>4", ] description = "Fast KML processing in python" diff --git a/tests/geometries/linestring_test.py b/tests/geometries/linestring_test.py index 379dae81..bfc773f8 100644 --- a/tests/geometries/linestring_test.py +++ b/tests/geometries/linestring_test.py @@ -132,7 +132,6 @@ def test_from_string_invalid_coordinates_non_numerical(self) -> None: "", ) - @pytest.mark.xfail(reason="pygeoif does not handle NaN values well") def test_from_string_invalid_coordinates_nan(self) -> None: line_string = LineString.class_from_string( '' From 65c6a6293eeef58b9bd3d28c47c79a050aeb6b65 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sat, 11 May 2024 14:02:33 +0100 Subject: [PATCH 7/8] fix type annotations --- .pre-commit-config.yaml | 4 ++-- fastkml/geometry.py | 2 -- fastkml/gx.py | 6 +++++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e4644638..cff6b6f7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,7 +25,7 @@ repos: hooks: - id: pyprojectsort - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.16 + rev: v0.17 hooks: - id: validate-pyproject - repo: https://github.com/ikamensh/flynt/ @@ -50,7 +50,7 @@ repos: hooks: - id: isort - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 'v0.4.3' + rev: 'v0.4.4' hooks: - id: ruff - repo: https://github.com/PyCQA/flake8 diff --git a/fastkml/geometry.py b/fastkml/geometry.py index 3ed4db7e..0245e133 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -647,7 +647,6 @@ def geometries(self) -> Optional[Iterable[geo.LinearRing]]: class Polygon(_Geometry): - outer_boundary_is: Optional[OuterBoundaryIs] inner_boundary_is: Optional[InnerBoundaryIs] @@ -835,7 +834,6 @@ def create_kml_geometry( class MultiGeometry(_Geometry): - kml_geometries: List[Union[Point, LineString, Polygon, LinearRing, Self]] def __init__( diff --git a/fastkml/gx.py b/fastkml/gx.py index b13508d1..88820ce2 100644 --- a/fastkml/gx.py +++ b/fastkml/gx.py @@ -157,7 +157,11 @@ def etree_elements( f"{name_spaces.get('gx', '')}coord", ) if self.coord: - element.text = " ".join([str(c) for c in self.coord.coords[0]]) + element.text = " ".join( + [ + str(c) for c in self.coord.coords[0] # type:ignore[misc] + ], + ) yield element element = config.etree.Element( # type: ignore[attr-defined] f"{name_spaces.get('gx', '')}angles", From 8c889a987c65952e5fd209a1b98bfdf367837d20 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sun, 12 May 2024 10:19:14 +0100 Subject: [PATCH 8/8] GeometryError class to handle mutually exclusive geometry and kml_coordinates --- fastkml/exceptions.py | 5 + fastkml/geometry.py | 233 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 227 insertions(+), 11 deletions(-) diff --git a/fastkml/exceptions.py b/fastkml/exceptions.py index 56226835..1d51ec90 100644 --- a/fastkml/exceptions.py +++ b/fastkml/exceptions.py @@ -30,3 +30,8 @@ class KMLWriteError(FastKMLError): class KMLSchemaError(FastKMLError): """Raised when there is an error with the KML Schema.""" + + +# geometry and kml_coordinates are mutually exclusive +class GeometryError(FastKMLError): + """Raised when there is an error with the geometry.""" diff --git a/fastkml/geometry.py b/fastkml/geometry.py index 0245e133..9bc0c3a0 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -18,6 +18,7 @@ import re from typing import Any from typing import Dict +from typing import Final from typing import Iterable from typing import List from typing import Optional @@ -39,6 +40,7 @@ from fastkml.base import _XMLObject from fastkml.enums import AltitudeMode from fastkml.enums import Verbosity +from fastkml.exceptions import GeometryError from fastkml.exceptions import KMLParseError from fastkml.exceptions import KMLWriteError from fastkml.helpers import bool_subelement @@ -78,6 +80,8 @@ ] AnyGeometryType = Union[GeometryType, MultiGeometryType] +MsgMutualExclusive: Final = "Geometry and kml coordinates are mutually exclusive" + def handle_invalid_geometry_error( *, @@ -146,8 +150,28 @@ def subelement_coordinates_kwarg( classes: Tuple[known_types, ...], strict: bool, ) -> Dict[str, LineType]: - # Clean up badly formatted tuples by stripping - # space following commas. + """ + Extracts coordinates from a subelement and returns them as a dictionary. + + Args: + ---- + element (Element): The XML element containing the coordinates. + ns (str): The namespace of the XML element. + name_spaces (Dict[str, str]): A dictionary mapping namespace prefixes to URIs. + node_name (str): The name of the XML node containing the coordinates. + kwarg (str): The name of the keyword argument to store the coordinates. + classes (Tuple[known_types, ...]): A tuple of known types for validation. + strict (bool): A flag indicating whether to raise an error for invalid geometry. + + Returns: + ------- + Dict[str, LineType]: A dictionary containing the extracted coordinates. + + Raises: + ------ + ValueError: If the coordinates are not in the expected format. + + """ try: latlons = re.sub(r", +", ",", element.text.strip()).split() except AttributeError: @@ -329,6 +353,17 @@ def __repr__(self) -> str: class Point(_Geometry): + """ + A geographic location defined by longitude, latitude, and (optional) altitude. + + When a Point is contained by a Placemark, the point itself determines the position + of the Placemark's name and icon. + When a Point is extruded, it is connected to the ground with a line. + This "tether" uses the current LineStyle. + + https://developers.google.com/kml/documentation/kmlreference#point + """ + kml_coordinates: Optional[Coordinates] def __init__( @@ -345,8 +380,30 @@ def __init__( kml_coordinates: Optional[Coordinates] = None, **kwargs: Any, ) -> None: + """ + Initialize a Point object. + + Args: + ---- + ns (Optional[str]): The namespace for the element. + name_spaces (Optional[Dict[str, str]]): The namespace dictionary for the + element. + id (Optional[str]): The ID of the element. + target_id (Optional[str]): The target ID of the element. + extrude (Optional[bool]): Whether to extrude the geometry. + tessellate (Optional[bool]): Whether to tessellate the geometry. + altitude_mode (Optional[AltitudeMode]): The altitude mode of the geometry. + geometry (Optional[geo.Point]): The geometry object. + kml_coordinates (Optional[Coordinates]): The KML coordinates of the point. + **kwargs (Any): Additional keyword arguments. + + Raises: + ------ + GeometryError: If both `geometry` and `kml_coordinates` are provided. + + """ if geometry is not None and kml_coordinates is not None: - raise ValueError("geometry and kml_coordinates are mutually exclusive") + raise GeometryError(MsgMutualExclusive) if kml_coordinates is None: kml_coordinates = ( Coordinates(coords=geometry.coords) # type: ignore[arg-type] @@ -366,7 +423,14 @@ def __init__( ) def __repr__(self) -> str: - """Create a string (c)representation for Point.""" + """ + Return a string representation of the Point object. + + Returns + ------- + str: The string representation of the Point object. + + """ return ( f"{self.__class__.__module__}.{self.__class__.__name__}(" f"ns={self.ns!r}, " @@ -382,10 +446,27 @@ def __repr__(self) -> str: ) def __bool__(self) -> bool: + """ + Check if the Point object has a valid geometry. + + Returns + ------- + bool: True if the Point object has a valid geometry, False otherwise. + + """ return bool(self.geometry) @property def geometry(self) -> Optional[geo.Point]: + """ + Get the geometry object of the Point. + + Returns + ------- + Optional[geo.Point]: The geometry object of the Point, + or None if it doesn't exist. + + """ if not self.kml_coordinates: return None try: @@ -407,6 +488,19 @@ def geometry(self) -> Optional[geo.Point]: class LineString(_Geometry): + """ + Defines a connected set of line segments. + + Use to specify the color, color mode, and width of the line. + When a LineString is extruded, the line is extended to the ground, forming a polygon + that looks somewhat like a wall or fence. + For extruded LineStrings, the line itself uses the current LineStyle, and the + extrusion uses the current PolyStyle. + See the KML Tutorial for examples of LineStrings (or paths). + + https://developers.google.com/kml/documentation/kmlreference#linestring + """ + def __init__( self, *, @@ -421,8 +515,29 @@ def __init__( kml_coordinates: Optional[Coordinates] = None, **kwargs: Any, ) -> None: + """ + Initialize a LineString object. + + Args: + ---- + ns (Optional[str]): The namespace of the element. + name_spaces (Optional[Dict[str, str]]): The namespaces used in the element. + id (Optional[str]): The ID of the element. + target_id (Optional[str]): The target ID of the element. + extrude (Optional[bool]): Whether to extrude the geometry. + tessellate (Optional[bool]): Whether to tessellate the geometry. + altitude_mode (Optional[AltitudeMode]): The altitude mode of the geometry. + geometry (Optional[geo.LineString]): The LineString geometry. + kml_coordinates (Optional[Coordinates]): The KML coordinates of the geometry. + **kwargs (Any): Additional keyword arguments. + + Raises: + ------ + ValueError: If both `geometry` and `kml_coordinates` are provided. + + """ if geometry is not None and kml_coordinates is not None: - raise ValueError("geometry and kml_coordinates are mutually exclusive") + raise GeometryError(MsgMutualExclusive) if kml_coordinates is None: kml_coordinates = Coordinates(coords=geometry.coords) if geometry else None self.kml_coordinates = kml_coordinates @@ -454,10 +569,27 @@ def __repr__(self) -> str: ) def __bool__(self) -> bool: + """ + Check if the LineString object is non-empty. + + Returns + ------- + bool: True if the LineString object is non-empty, False otherwise. + + """ return bool(self.geometry) @property def geometry(self) -> Optional[geo.LineString]: + """ + Get the LineString geometry. + + Returns + ------- + Optional[geo.LineString]: The LineString geometry, or None if it doesn't + exist. + + """ if not self.kml_coordinates: return None try: @@ -479,6 +611,16 @@ def geometry(self) -> Optional[geo.LineString]: class LinearRing(LineString): + """ + Defines a closed line string, typically the outer boundary of a Polygon. + + Optionally, a LinearRing can also be used as the inner boundary of a Polygon to + create holes in the Polygon. + A Polygon can contain multiple elements used as inner boundaries. + + https://developers.google.com/kml/documentation/kmlreference#linearring + """ + def __init__( self, *, @@ -524,6 +666,15 @@ def __repr__(self) -> str: @property def geometry(self) -> Optional[geo.LinearRing]: + """ + Get the geometry of the LinearRing. + + Returns + ------- + Optional[geo.LinearRing]: The geometry of the LinearRing, + or None if kml_coordinates is not set or if there is a DimensionError. + + """ if not self.kml_coordinates: return None try: @@ -536,6 +687,16 @@ def geometry(self) -> Optional[geo.LinearRing]: class OuterBoundaryIs(_XMLObject): + """ + Represents the outer boundary of a polygon in KML. + + Attributes + ---------- + kml_geometry (Optional[LinearRing]): The KML geometry representing the outer + boundary. + + """ + _default_ns = config.KMLNS kml_geometry: Optional[LinearRing] @@ -549,7 +710,7 @@ def __init__( **kwargs: Any, ) -> None: if geometry is not None and kml_geometry is not None: - raise ValueError("geometry and kml_coordinates are mutually exclusive") + raise GeometryError(MsgMutualExclusive) if kml_geometry is None: kml_geometry = ( LinearRing(ns=ns, name_spaces=name_spaces, geometry=geometry) @@ -589,6 +750,8 @@ def geometry(self) -> Optional[geo.LinearRing]: class InnerBoundaryIs(_XMLObject): + """Represents the inner boundary of a polygon in KML.""" + _default_ns = config.KMLNS kml_geometries: List[LinearRing] @@ -602,7 +765,7 @@ def __init__( **kwargs: Any, ) -> None: if geometries is not None and kml_geometries is not None: - raise ValueError("geometries and kml_coordinates are mutually exclusive") + raise GeometryError(MsgMutualExclusive) if kml_geometries is None: kml_geometries = ( [ @@ -620,15 +783,24 @@ def __init__( ) def __bool__(self) -> bool: + """ + Returns True if any of the inner boundary geometries exist, False otherwise. + """ return any(b.geometry for b in self.kml_geometries) @classmethod def get_tag_name(cls) -> str: - """Return the tag name.""" + """ + Returns the tag name of the element. + """ return "innerBoundaryIs" @property def geometries(self) -> Optional[Iterable[geo.LinearRing]]: + """ + Returns the list of LinearRing objects representing the inner boundary. + If no inner boundary geometries exist, returns None. + """ if not self.kml_geometries: return None return [lr.geometry for lr in self.kml_geometries if lr.geometry] @@ -647,6 +819,33 @@ def geometries(self) -> Optional[Iterable[geo.LinearRing]]: class Polygon(_Geometry): + """ + A Polygon is defined by an outer boundary and 0 or more inner boundaries. + + The boundaries, in turn, are defined by LinearRings. + When a Polygon is extruded, its boundaries are connected to the ground to form + additional polygons, which gives the appearance of a building or a box. + Extruded Polygons use for their color, color mode, and fill. + + The for polygons must be specified in counterclockwise order. + + The outer boundary is represented by the `outer_boundary_is` attribute, + which is an instance of the `OuterBoundaryIs` class. + The inner boundaries are represented by the `inner_boundary_is` attribute, + which is an instance of the `InnerBoundaryIs` class. + + The `geometry` property returns a `geo.Polygon` object representing the + geometry of the Polygon. + + Example usage: + ``` + polygon = Polygon(outer_boundary_is=outer_boundary, inner_boundary_is=inner_boundary) + print(polygon.geometry) + ``` + + https://developers.google.com/kml/documentation/kmlreference#polygon + """ + outer_boundary_is: Optional[OuterBoundaryIs] inner_boundary_is: Optional[InnerBoundaryIs] @@ -666,7 +865,7 @@ def __init__( **kwargs: Any, ) -> None: if outer_boundary_is is not None and geometry is not None: - raise ValueError("outer_boundary_is and geometry are mutually exclusive") + raise GeometryError(MsgMutualExclusive) if geometry is not None: outer_boundary_is = OuterBoundaryIs(geometry=geometry.exterior) inner_boundary_is = InnerBoundaryIs(geometries=geometry.interiors) @@ -834,6 +1033,10 @@ def create_kml_geometry( class MultiGeometry(_Geometry): + """ + A container for zero or more geometry primitives associated with the same feature. + """ + kml_geometries: List[Union[Point, LineString, Polygon, LinearRing, Self]] def __init__( @@ -853,7 +1056,7 @@ def __init__( **kwargs: Any, ) -> None: if kml_geometries is not None and geometry is not None: - raise ValueError("kml_geometries and geometry are mutually exclusive") + raise GeometryError(MsgMutualExclusive) if geometry is not None: kml_geometries = [ create_kml_geometry( # type: ignore[misc] @@ -879,10 +1082,15 @@ def __init__( ) def __bool__(self) -> bool: + """ + Returns True if the MultiGeometry has a geometry, False otherwise. + """ return bool(self.geometry) def __repr__(self) -> str: - """Create a string (c)representation for MultiGeometry.""" + """ + Returns a string representation of the MultiGeometry. + """ return ( f"{self.__class__.__module__}.{self.__class__.__name__}(" f"ns={self.ns!r}, " @@ -899,6 +1107,9 @@ def __repr__(self) -> str: @property def geometry(self) -> Optional[MultiGeometryType]: + """ + Returns the geometry of the MultiGeometry. + """ return create_multigeometry( [geom.geometry for geom in self.kml_geometries if geom.geometry], )