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/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 8b164fde..9bc0c3a0 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -16,9 +16,10 @@ import logging import re -from functools import partial from typing import Any from typing import Dict +from typing import Final +from typing import Iterable from typing import List from typing import Optional from typing import Sequence @@ -32,20 +33,24 @@ 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 from fastkml.base import _BaseObject 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 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.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 @@ -75,6 +80,8 @@ ] AnyGeometryType = Union[GeometryType, MultiGeometryType] +MsgMutualExclusive: Final = "Geometry and kml coordinates are mutually exclusive" + def handle_invalid_geometry_error( *, @@ -122,13 +129,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) @@ -143,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: @@ -192,6 +219,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) @@ -229,7 +267,6 @@ class _Geometry(_BaseObject): extrude: Optional[bool] tessellate: Optional[bool] altitude_mode: Optional[AltitudeMode] - _geometry: Optional[AnyGeometryType] def __init__( self, @@ -241,7 +278,6 @@ def __init__( extrude: Optional[bool] = None, tessellate: Optional[bool] = None, altitude_mode: Optional[AltitudeMode] = None, - geometry: Optional[AnyGeometryType] = None, **kwargs: Any, ) -> None: """ @@ -267,7 +303,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.""" @@ -280,112 +315,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 _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 _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, @@ -420,6 +353,16 @@ def _get_kwargs( 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] @@ -437,14 +380,37 @@ 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: - 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, @@ -453,12 +419,18 @@ def __init__( extrude=extrude, tessellate=tessellate, altitude_mode=altitude_mode, - geometry=geometry, **kwargs, ) 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}, " @@ -474,63 +446,61 @@ def __repr__(self) -> str: ) def __bool__(self) -> bool: - return bool(self.kml_coordinates) + """ + 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]: - if not self.kml_coordinates: - return None - return geo.Point.from_coordinates(self.kml_coordinates.coords) + def geometry(self) -> Optional[geo.Point]: + """ + Get the geometry object of the Point. - 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 + Returns + ------- + Optional[geo.Point]: The geometry object of the Point, + or None if it doesn't exist. - @classmethod - def _get_geometry( - cls, - *, - ns: str, - element: Element, - strict: bool, - ) -> Optional[geo.Point]: - coords = cls._get_coordinates(ns=ns, element=element, strict=strict) + """ + if not self.kml_coordinates: + return None try: - return geo.Point.from_coordinates(coords) - except (IndexError, TypeError) as e: - handle_invalid_geometry_error( - error=e, - element=element, - strict=strict, - ) - return None + return geo.Point.from_coordinates(self.kml_coordinates.coords) + except (DimensionError, TypeError): + 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): + """ + 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, *, @@ -541,9 +511,36 @@ 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: + """ + 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 GeometryError(MsgMutualExclusive) + 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, @@ -552,7 +549,6 @@ def __init__( extrude=extrude, tessellate=tessellate, altitude_mode=altitude_mode, - geometry=geometry, **kwargs, ) @@ -572,38 +568,59 @@ 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: + """ + Check if the LineString object is non-empty. - @classmethod - def _get_geometry( - cls, - *, - ns: str, - element: Element, - strict: bool, - ) -> Optional[geo.LineString]: - coords = cls._get_coordinates(ns=ns, element=element, strict=strict) + 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: - return geo.LineString.from_coordinates(coords) - except (IndexError, TypeError, DimensionError) as e: - handle_invalid_geometry_error( - error=e, - element=element, - strict=strict, - ) - return None + return geo.LineString.from_coordinates(self.kml_coordinates.coords) + except DimensionError: + return None + + +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): + """ + 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, *, @@ -614,7 +631,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__( @@ -626,6 +644,7 @@ def __init__( tessellate=tessellate, altitude_mode=altitude_mode, geometry=geometry, + kml_coordinates=kml_coordinates, **kwargs, ) @@ -645,27 +664,191 @@ 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: + return cast( + geo.LinearRing, + geo.LinearRing.from_coordinates(self.kml_coordinates.coords), + ) + except DimensionError: + return None + + +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] + + 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 GeometryError(MsgMutualExclusive) + 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.geometry) + @classmethod - def _get_geometry( - cls, + 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): + """Represents the inner boundary of a polygon in KML.""" + + _default_ns = config.KMLNS + kml_geometries: List[LinearRing] + + def __init__( + self, *, - 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, + 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 GeometryError(MsgMutualExclusive) + if kml_geometries is None: + kml_geometries = ( + [ + LinearRing(ns=ns, name_spaces=name_spaces, geometry=lr) + for lr in geometries + ] + if geometries + else None ) - return None + self.kml_geometries = list(kml_geometries) if kml_geometries else [] + super().__init__( + ns=ns, + name_spaces=name_spaces, + **kwargs, + ) + + 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: + """ + 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] + + +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): + """ + 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] + def __init__( self, *, @@ -676,9 +859,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 GeometryError(MsgMutualExclusive) + 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, @@ -687,10 +879,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 not self.inner_boundary_is: + 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 ( @@ -707,95 +914,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( @@ -832,108 +971,6 @@ def create_multigeometry( return geo.GeometryCollection(geometries) -class MultiGeometry(_Geometry): - map_to_kml = { - geo.Point: Point, - geo.LineString: LineString, - geo.Polygon: Polygon, - geo.LinearRing: LinearRing, - } - multi_geometries = ( - geo.MultiPoint, - geo.MultiLineString, - geo.MultiPolygon, - geo.GeometryCollection, - ) - - def __init__( - self, - *, - 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: MultiGeometryType, - **kwargs: Any, - ) -> None: - super().__init__( - ns=ns, - name_spaces=name_spaces, - id=id, - target_id=target_id, - extrude=extrude, - tessellate=tessellate, - altitude_mode=altitude_mode, - geometry=geometry, - **kwargs, - ) - - def __repr__(self) -> str: - """Create a string (c)representation for MultiGeometry.""" - return ( - f"{self.__class__.__module__}.{self.__class__.__name__}(" - f"ns={self.ns!r}, " - f"name_spaces={self.name_spaces!r}, " - f"id={self.id!r}, " - f"target_id={self.target_id!r}, " - 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 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 - - @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) - - def create_kml_geometry( geometry: Union[GeoType, GeoCollectionType], *, @@ -993,3 +1030,98 @@ def create_kml_geometry( # 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): + """ + A container for zero or more geometry primitives associated with the same feature. + """ + + kml_geometries: List[Union[Point, LineString, Polygon, LinearRing, Self]] + + def __init__( + self, + *, + 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, + 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 GeometryError(MsgMutualExclusive) + 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, + id=id, + target_id=target_id, + extrude=extrude, + tessellate=tessellate, + altitude_mode=altitude_mode, + **kwargs, + ) + + def __bool__(self) -> bool: + """ + Returns True if the MultiGeometry has a geometry, False otherwise. + """ + return bool(self.geometry) + + def __repr__(self) -> str: + """ + Returns a string representation of the MultiGeometry. + """ + return ( + f"{self.__class__.__module__}.{self.__class__.__name__}(" + f"ns={self.ns!r}, " + f"name_spaces={self.name_spaces!r}, " + f"id={self.id!r}, " + f"target_id={self.target_id!r}, " + 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}," + ")" + ) + + @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], + ) + + +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, + ), +) diff --git a/fastkml/gx.py b/fastkml/gx.py index 991193eb..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", @@ -225,7 +229,6 @@ def __init__( extrude=extrude, tessellate=tessellate, altitude_mode=altitude_mode, - geometry=None, **kwargs, ) @@ -388,7 +391,6 @@ def __init__( extrude=extrude, tessellate=tessellate, altitude_mode=altitude_mode, - geometry=None, **kwargs, ) 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/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/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/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/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..bfc773f8 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( @@ -104,7 +103,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 +114,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.""" @@ -133,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( '' diff --git a/tests/geometries/multigeometry_test.py b/tests/geometries/multigeometry_test.py index 67c2b2c8..2ae32abe 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 " @@ -285,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( ( @@ -353,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 042c92a8..d0389647 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,38 @@ 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( + '', + ) + + 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 +214,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..cecb1cdc 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 = """ @@ -172,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