From f35b9423a6bb8e35f0ceb79ced4e973bfce653da Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sat, 14 Oct 2023 21:52:34 +0100 Subject: [PATCH 1/9] Scaffold Track #242 --- fastkml/geometry.py | 4 +- fastkml/gx.py | 61 +++++++++++++++++++++++++------ tests/geometries/geometry_test.py | 8 ---- tests/gx_test.py | 47 ++++++++++++++++++++++++ tests/oldunit_test.py | 43 ---------------------- 5 files changed, 100 insertions(+), 63 deletions(-) diff --git a/fastkml/geometry.py b/fastkml/geometry.py index b66e8dd6..66ba4923 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -518,7 +518,9 @@ def __init__( extrude: Optional[bool] = False, tessellate: Optional[bool] = False, altitude_mode: Optional[AltitudeMode] = None, - geometry: Optional[AnyGeometryType] = None, + geometry: Union[ + Optional[AnyGeometryType], Sequence[Optional[geo.Point]] + ] = None, ) -> None: """ diff --git a/fastkml/gx.py b/fastkml/gx.py index 80c58102..7ba1c09d 100644 --- a/fastkml/gx.py +++ b/fastkml/gx.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012 - 2022 Christian Ledermann +# Copyright (C) 2012 - 2023 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 @@ -75,20 +75,21 @@ The complete XML schema for elements in this extension namespace is located at http://developers.google.com/kml/schema/kml22gx.xsd. """ - +import datetime import logging from typing import List from typing import Optional +from typing import Sequence from typing import Union from typing import cast -from pygeoif.geometry import GeometryCollection -from pygeoif.geometry import LineString -from pygeoif.geometry import MultiLineString +import pygeoif.geometry as geo from pygeoif.types import PointType from fastkml.config import GXNS as NS +from fastkml.enums import AltitudeMode from fastkml.geometry import Geometry +from fastkml.geometry import _Geometry from fastkml.types import Element logger = logging.getLogger(__name__) @@ -107,12 +108,12 @@ def __init__( super().__init__(ns, id) self.ns = NS if ns is None else ns - def _get_geometry(self, element: Element) -> Optional[LineString]: + def _get_geometry(self, element: Element) -> Optional[geo.LineString]: # Track if element.tag == (f"{self.ns}Track"): coords = self._get_coordinates(element) self._get_geometry_spec(element) - return LineString( + return geo.LineString( coords, ) return None @@ -120,7 +121,7 @@ def _get_geometry(self, element: Element) -> Optional[LineString]: def _get_multigeometry( self, element: Element, - ) -> Union[MultiLineString, GeometryCollection, None]: + ) -> Union[geo.MultiLineString, geo.GeometryCollection, None]: # MultiTrack geoms = [] if element.tag == (f"{self.ns}MultiTrack"): @@ -128,16 +129,16 @@ def _get_multigeometry( for track in tracks: self._get_geometry_spec(track) geoms.append( - LineString( + geo.LineString( self._get_coordinates(track), ) ) geom_types = {geom.geom_type for geom in geoms} if len(geom_types) > 1: - return GeometryCollection(geoms) + return geo.GeometryCollection(geoms) if "LineString" in geom_types: - return MultiLineString.from_linestrings(*geoms) + return geo.MultiLineString.from_linestrings(*geoms) return None def _get_coordinates(self, element: Element) -> List[PointType]: @@ -150,4 +151,42 @@ def _get_coordinates(self, element: Element) -> List[PointType]: return [] # type: ignore[unreachable] +class Track(_Geometry): + """ + A track describes how an object moves through the world over a given time period. + + This feature allows you to create one visible object in Google Earth + (either a Point icon or a Model) that encodes multiple positions for the same object + for multiple times. In Google Earth, the time slider allows the user to move the + view through time, which animates the position of the object. + + Tracks are a more efficient mechanism for associating time data with visible + Features, since you create only one Feature, which can be associated with multiple + time elements as the object moves through space. + """ + + def __init__( + self, + *, + ns: Optional[str] = None, + id: Optional[str] = None, + target_id: Optional[str] = None, + extrude: Optional[bool] = False, + tessellate: Optional[bool] = False, + altitude_mode: Optional[AltitudeMode] = None, + geometry: Sequence[Optional[geo.Point]], + times: Optional[Sequence[Optional[datetime.datetime]]] = None, + ) -> None: + super().__init__( + ns=ns, + id=id, + target_id=target_id, + extrude=extrude, + tessellate=tessellate, + altitude_mode=altitude_mode, + geometry=geometry, + ) + self.times = times + + __all__ = ["GxGeometry"] diff --git a/tests/geometries/geometry_test.py b/tests/geometries/geometry_test.py index 24c190c9..19d4528f 100644 --- a/tests/geometries/geometry_test.py +++ b/tests/geometries/geometry_test.py @@ -27,10 +27,6 @@ from tests.base import StdLibrary -class TestStdLibrary(StdLibrary): - """Test with the standard library.""" - - class TestGetGeometry(StdLibrary): def test_altitude_mode(self) -> None: doc = """ @@ -392,10 +388,6 @@ def test_from_string_omitting_ns(self) -> None: assert g.tessellate is True -class TestLxml(Lxml, TestStdLibrary): - """Test with lxml.""" - - class TestGetGeometryLxml(Lxml, TestGetGeometry): """Test with lxml.""" diff --git a/tests/gx_test.py b/tests/gx_test.py index 66d43167..de29048d 100644 --- a/tests/gx_test.py +++ b/tests/gx_test.py @@ -15,6 +15,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Test the gx classes.""" +from fastkml.gx import GxGeometry from tests.base import Lxml from tests.base import StdLibrary @@ -23,5 +24,51 @@ class TestStdLibrary(StdLibrary): """Test with the standard library.""" +class TestGetGxGeometry(StdLibrary): + def test_track(self) -> None: + doc = """ + 2020-01-01T00:00:00Z + 2020-01-01T00:10:00Z + 0.000000 0.000000 + 1.000000 1.000000 + """ + + g = GxGeometry() + g.from_string(doc) + assert g.geometry.__geo_interface__ == { + "type": "LineString", + "bbox": (0.0, 0.0, 1.0, 1.0), + "coordinates": ((0.0, 0.0), (1.0, 1.0)), + } + + def test_multitrack(self) -> None: + doc = """ + + + 2020-01-01T00:00:00Z + 2020-01-01T00:10:00Z + 0.000000 0.000000 + 1.000000 0.000000 + + + 2020-01-01T00:10:00Z + 2020-01-01T00:20:00Z + 0.000000 1.000000 + 1.000000 1.000000 + + + """ + + g = GxGeometry() + g.from_string(doc) + assert len(g.geometry) == 2 + + class TestLxml(Lxml, TestStdLibrary): """Test with lxml.""" + + +class TestLxmlGetGxGeometry(Lxml, TestGetGxGeometry): + """Test with lxml.""" diff --git a/tests/oldunit_test.py b/tests/oldunit_test.py index 348c372e..dde9bc86 100644 --- a/tests/oldunit_test.py +++ b/tests/oldunit_test.py @@ -31,7 +31,6 @@ from fastkml import kml from fastkml import styles from fastkml.geometry import Geometry -from fastkml.gx import GxGeometry try: import lxml @@ -1694,48 +1693,6 @@ def test_nested_multigeometry(self): assert len(list(second_multigeometry.geoms)) == 2 -class TestGetGxGeometry: - def test_track(self) -> None: - doc = """ - 2020-01-01T00:00:00Z - 2020-01-01T00:10:00Z - 0.000000 0.000000 - 1.000000 1.000000 - """ - - g = GxGeometry() - g.from_string(doc) - assert g.geometry.__geo_interface__ == { - "type": "LineString", - "bbox": (0.0, 0.0, 1.0, 1.0), - "coordinates": ((0.0, 0.0), (1.0, 1.0)), - } - - def test_multitrack(self) -> None: - doc = """ - - - 2020-01-01T00:00:00Z - 2020-01-01T00:10:00Z - 0.000000 0.000000 - 1.000000 0.000000 - - - 2020-01-01T00:10:00Z - 2020-01-01T00:20:00Z - 0.000000 1.000000 - 1.000000 1.000000 - - - """ - - g = GxGeometry() - g.from_string(doc) - assert len(g.geometry) == 2 - - class TestBaseFeature: def test_address_string(self) -> None: f = kml._Feature() From 4fb9034fed8d0d9188902d4d94f703b7330985f6 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sun, 15 Oct 2023 15:32:01 +0100 Subject: [PATCH 2/9] Implement gx:Track --- fastkml/config.py | 11 ++- fastkml/geometry.py | 2 +- fastkml/gx.py | 167 ++++++++++++++++++++++++++++++++++++-- tests/gx_test.py | 192 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 362 insertions(+), 10 deletions(-) diff --git a/fastkml/config.py b/fastkml/config.py index f7970c13..699ce5a3 100644 --- a/fastkml/config.py +++ b/fastkml/config.py @@ -51,13 +51,16 @@ def set_etree_implementation(implementation: ModuleType) -> None: ATOMNS = "{http://www.w3.org/2005/Atom}" # noqa: FS003 GXNS = "{http://www.google.com/kml/ext/2.2}" # noqa: FS003 -DEFAULT_NAME_SPACES = { - "kml": KMLNS[1:-1], - "atom": ATOMNS[1:-1], - "gx": GXNS[1:-1], +NAME_SPACES = { + "kml": KMLNS, + "atom": ATOMNS, + "gx": GXNS, } +DEFAULT_NAME_SPACES = {k: v[1:-1] for k, v in NAME_SPACES.items()} + + def register_namespaces(**namespaces: str) -> None: """Register namespaces for use in etree.ElementTree.parse().""" try: diff --git a/fastkml/geometry.py b/fastkml/geometry.py index 66ba4923..f8c194db 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -547,7 +547,7 @@ def __repr__(self) -> str: f"target_id={self.target_id!r}, " f"extrude={self.extrude!r}, " f"tessellate={self.tessellate!r}, " - f"altitude_mode={self.altitude_mode!r} " + f"altitude_mode={self.altitude_mode} " f"geometry={self.geometry!r}" f")" ) diff --git a/fastkml/gx.py b/fastkml/gx.py index 7ba1c09d..a66fdb39 100644 --- a/fastkml/gx.py +++ b/fastkml/gx.py @@ -77,17 +77,24 @@ """ import datetime import logging +from dataclasses import dataclass +from itertools import zip_longest +from typing import Any +from typing import Dict +from typing import Iterator from typing import List from typing import Optional from typing import Sequence from typing import Union from typing import cast +import dateutil.parser import pygeoif.geometry as geo from pygeoif.types import PointType -from fastkml.config import GXNS as NS +import fastkml.config as config from fastkml.enums import AltitudeMode +from fastkml.enums import Verbosity from fastkml.geometry import Geometry from fastkml.geometry import _Geometry from fastkml.types import Element @@ -106,7 +113,7 @@ def __init__( like gx:Track """ super().__init__(ns, id) - self.ns = NS if ns is None else ns + self.ns = config.GXNS if ns is None else ns def _get_geometry(self, element: Element) -> Optional[geo.LineString]: # Track @@ -151,6 +158,72 @@ def _get_coordinates(self, element: Element) -> List[PointType]: return [] # type: ignore[unreachable] +@dataclass(frozen=True) +class Angle: + """ + The gx:angles element specifies the heading, tilt, and roll. + + The angles are specified in degrees, and the + default values are 0 (heading and tilt) and 0 (roll). The angles + are specified in the following order: heading, tilt, roll. + """ + + heading: float = 0.0 + tilt: float = 0.0 + roll: float = 0.0 + + +@dataclass(frozen=True) +class TrackItem: + """ + A track item describes an object moving through the world over a given time period. + """ + + when: Optional[datetime.datetime] = None + coord: Optional[geo.Point] = None + angle: Optional[Angle] = None + + def etree_elements( + self, + *, + precision: Optional[int] = None, + verbosity: Verbosity = Verbosity.normal, + name_spaces: Optional[Dict[str, str]] = None, + ) -> Iterator[Element]: + name_spaces = name_spaces or {} + name_spaces = {**config.NAME_SPACES, **name_spaces} + element: Element = config.etree.Element( # type: ignore[attr-defined] + f"{name_spaces.get('kml', '')}when" + ) + if self.when: + element.text = self.when.isoformat() + yield element + element = config.etree.Element( # type: ignore[attr-defined] + f"{name_spaces.get('gx', '')}coord" + ) + if self.coord: + element.text = " ".join([str(c) for c in self.coord.coords[0]]) + yield element + element = config.etree.Element( # type: ignore[attr-defined] + f"{name_spaces.get('gx', '')}angles" + ) + if self.angle: + element.text = " ".join( + [str(self.angle.heading), str(self.angle.tilt), str(self.angle.roll)] + ) + yield element + + +def track_items_to_geometry(track_items: Sequence[TrackItem]) -> geo.LineString: + return geo.LineString.from_points( + *[item.coord for item in track_items if item.coord is not None] + ) + + +def linestring_to_track_items(linestring: geo.LineString) -> List[TrackItem]: + return [TrackItem(coord=point) for point in linestring.geoms] + + class Track(_Geometry): """ A track describes how an object moves through the world over a given time period. @@ -174,9 +247,16 @@ def __init__( extrude: Optional[bool] = False, tessellate: Optional[bool] = False, altitude_mode: Optional[AltitudeMode] = None, - geometry: Sequence[Optional[geo.Point]], - times: Optional[Sequence[Optional[datetime.datetime]]] = None, + geometry: Optional[geo.LineString] = None, + track_items: Optional[Sequence[TrackItem]] = None, ) -> None: + if geometry and track_items: + raise ValueError("Cannot specify both geometry and track_items") + if geometry: + track_items = linestring_to_track_items(geometry) + self.track_items = track_items + if track_items: + geometry = track_items_to_geometry(track_items) super().__init__( ns=ns, id=id, @@ -186,7 +266,84 @@ def __init__( altitude_mode=altitude_mode, geometry=geometry, ) - self.times = times + self.track_items + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(" + f"ns={self.ns!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"track_items={self.track_items!r}" + ")" + ) + + def etree_element( + self, + precision: Optional[int] = None, + verbosity: Verbosity = Verbosity.normal, + name_spaces: Optional[Dict[str, str]] = None, + ) -> Element: + self.__name__ = self.__class__.__name__ + element = super().etree_element(precision=precision, verbosity=verbosity) + if self.track_items: + for track_item in self.track_items: + for track_item_element in track_item.etree_elements( + precision=precision, verbosity=verbosity, name_spaces=name_spaces + ): + element.append(track_item_element) + return element + + @classmethod + def track_items_kwargs_from_element( + cls, + *, + ns: str, + element: Element, + strict: bool, + ) -> List[TrackItem]: + time_stamps: List[Optional[datetime.datetime]] = [] + for time_stamp in element.findall(f"{config.KMLNS}when"): + if time_stamp is not None and time_stamp.text: + time_stamps.append(dateutil.parser.parse(time_stamp.text)) + else: + time_stamps.append(None) + coords: List[Optional[geo.Point]] = [] + for coord in element.findall(f"{config.GXNS}coord"): + if coord is not None and coord.text: + coords.append( + geo.Point(*[float(c) for c in coord.text.strip().split()]) + ) + else: + coords.append(None) + angles: List[Optional[Angle]] = [] + for angle in element.findall(f"{config.GXNS}angles"): + if angle is not None and angle.text: + angles.append(Angle(*[float(a) for a in angle.text.strip().split()])) + else: + angles.append(None) + track_items = [ + TrackItem(when=when, coord=coord, angle=angle) + for when, coord, angle in zip_longest(time_stamps, coords, angles) + ] + return track_items + + @classmethod + def _get_kwargs( + cls, + *, + ns: str, + element: Element, + strict: bool, + ) -> Dict[str, Any]: + kwargs = super()._get_kwargs(ns=ns, element=element, strict=strict) + kwargs["track_items"] = cls.track_items_kwargs_from_element( + ns=ns, element=element, strict=strict + ) + return kwargs __all__ = ["GxGeometry"] diff --git a/tests/gx_test.py b/tests/gx_test.py index de29048d..ad511d82 100644 --- a/tests/gx_test.py +++ b/tests/gx_test.py @@ -15,7 +15,16 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Test the gx classes.""" +import datetime + +import pygeoif.geometry as geo +from dateutil.tz import tzutc + +from fastkml.enums import AltitudeMode +from fastkml.gx import Angle from fastkml.gx import GxGeometry +from fastkml.gx import Track +from fastkml.gx import TrackItem from tests.base import Lxml from tests.base import StdLibrary @@ -66,9 +75,192 @@ def test_multitrack(self) -> None: assert len(g.geometry) == 2 +class TestTrack(StdLibrary): + """Test gx.Track.""" + + def test_track_from_linestring(self) -> None: + ls = geo.LineString(((1, 2), (2, 0))) + + track = Track( + ns="", + id="track1", + target_id="track2", + altitude_mode=AltitudeMode.absolute, + extrude=True, + tessellate=True, + geometry=ls, + ) + + assert "1" in track.to_string() + assert "tessellate>1" in track.to_string() + assert "altitudeMode>absolute" in track.to_string() + assert "coord>" in track.to_string() + assert "angles" in track.to_string() + assert "when" in track.to_string() + assert "angles>" not in track.to_string() + assert "when>" not in track.to_string() + assert repr(track) == ( + "Track(ns='', id='track1', target_id='track2', extrude=True, " + "tessellate=True, altitude_mode=AltitudeMode.absolute, " + "track_items=[TrackItem(when=None, coord=Point(1, 2), angle=None), " + "TrackItem(when=None, coord=Point(2, 0), angle=None)])" + ) + + def test_track_from_track_items(self) -> None: + time1 = datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) + angle = Angle() + track_items = [TrackItem(when=time1, coord=geo.Point(1, 2), angle=angle)] + + track = Track( + ns="", + track_items=track_items, + ) + + assert "when>" in track.to_string() + assert ">2023-01-01T00:00:00+00:00" in track.to_string() + assert ">1 2" in track.to_string() + assert ">0.0 0.0 0.0 None: + time1 = datetime.datetime(2023, 1, 1, 0, 0, 0, tzinfo=datetime.timezone.utc) + angle = Angle() + track_items = [TrackItem(when=time1, coord=None, angle=angle)] + + track = Track( + ns="", + track_items=track_items, + ) + + assert "when>" in track.to_string() + assert ">2023-01-01T00:00:00+00:00" not in track.to_string() + assert "coord" in track.to_string() + assert "angles>" in track.to_string() + assert ">0.0 0.0 0.0 None: + doc = """ + + 2010-05-28T02:02:09Z + 2010-05-28T02:02:35Z + 2010-05-28T02:02:44Z + 2010-05-28T02:02:53Z + 2010-05-28T02:02:54Z + 2010-05-28T02:02:55Z + 2010-05-28T02:02:56Z + + 45.54676 66.2342 77.0 + + 1 2 3 + 1 2 3 + 1 2 3 + 1 2 3 + 1 2 3 + 1 2 3 + -122.207881 37.371915 156.000000 + -122.205712 37.373288 152.000000 + -122.204678 37.373939 147.000000 + -122.203572 37.374630 142.199997 + + -122.203451 37.374706 141.800003 + -122.203329 37.374780 141.199997 + -122.203207 37.374857 140.199997 + + """ + expected_track = Track( + ns="", + id="", + target_id="", + extrude=None, + tessellate=None, + altitude_mode=None, + track_items=[ + TrackItem( + when=datetime.datetime(2010, 5, 28, 2, 2, 9, tzinfo=tzutc()), + coord=geo.Point(-122.207881, 37.371915, 156.0), + angle=Angle(heading=45.54676, tilt=66.2342, roll=77.0), + ), + TrackItem( + when=datetime.datetime(2010, 5, 28, 2, 2, 35, tzinfo=tzutc()), + coord=geo.Point(-122.205712, 37.373288, 152.0), + angle=None, + ), + TrackItem( + when=datetime.datetime(2010, 5, 28, 2, 2, 44, tzinfo=tzutc()), + coord=geo.Point(-122.204678, 37.373939, 147.0), + angle=Angle(heading=1.0, tilt=2.0, roll=3.0), + ), + TrackItem( + when=datetime.datetime(2010, 5, 28, 2, 2, 53, tzinfo=tzutc()), + coord=geo.Point(-122.203572, 37.37463, 142.199997), + angle=Angle(heading=1.0, tilt=2.0, roll=3.0), + ), + TrackItem( + when=datetime.datetime(2010, 5, 28, 2, 2, 54, tzinfo=tzutc()), + coord=None, + angle=Angle(heading=1.0, tilt=2.0, roll=3.0), + ), + TrackItem( + when=datetime.datetime(2010, 5, 28, 2, 2, 55, tzinfo=tzutc()), + coord=geo.Point(-122.203451, 37.374706, 141.800003), + angle=Angle(heading=1.0, tilt=2.0, roll=3.0), + ), + TrackItem( + when=datetime.datetime(2010, 5, 28, 2, 2, 56, tzinfo=tzutc()), + coord=geo.Point(-122.203329, 37.37478, 141.199997), + angle=Angle(heading=1.0, tilt=2.0, roll=3.0), + ), + TrackItem( + when=None, + coord=geo.Point(-122.203207, 37.374857, 140.199997), + angle=Angle(heading=1.0, tilt=2.0, roll=3.0), + ), + ], + ) + track = Track.class_from_string(doc, ns="") + + assert repr(track) == repr(expected_track) + assert track.to_string() == expected_track.to_string() + assert track.geometry == geo.LineString( + ( + (-122.207881, 37.371915, 156.0), + (-122.205712, 37.373288, 152.0), + (-122.204678, 37.373939, 147.0), + (-122.203572, 37.37463, 142.199997), + (-122.203451, 37.374706, 141.800003), + (-122.203329, 37.37478, 141.199997), + (-122.203207, 37.374857, 140.199997), + ) + ) + + class TestLxml(Lxml, TestStdLibrary): """Test with lxml.""" class TestLxmlGetGxGeometry(Lxml, TestGetGxGeometry): """Test with lxml.""" + + +class TestLxmlTrack(Lxml, TestTrack): + """Test with lxml.""" From b8f475c3390948cc8c891f21f9c248ad593203da Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sun, 15 Oct 2023 15:34:14 +0100 Subject: [PATCH 3/9] improve documentation for TrackItem --- fastkml/gx.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/fastkml/gx.py b/fastkml/gx.py index a66fdb39..a954f790 100644 --- a/fastkml/gx.py +++ b/fastkml/gx.py @@ -175,9 +175,7 @@ class Angle: @dataclass(frozen=True) class TrackItem: - """ - A track item describes an object moving through the world over a given time period. - """ + """A track item describes an objects position and heading at a specific time.""" when: Optional[datetime.datetime] = None coord: Optional[geo.Point] = None From 6065dd79973f423bf36ff7f986855f68b820cb92 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sun, 15 Oct 2023 15:43:57 +0100 Subject: [PATCH 4/9] remove unused types --- fastkml/geometry.py | 4 +--- tests/gx_test.py | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/fastkml/geometry.py b/fastkml/geometry.py index f8c194db..435033f2 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -518,9 +518,7 @@ def __init__( extrude: Optional[bool] = False, tessellate: Optional[bool] = False, altitude_mode: Optional[AltitudeMode] = None, - geometry: Union[ - Optional[AnyGeometryType], Sequence[Optional[geo.Point]] - ] = None, + geometry: Optional[AnyGeometryType] = None, ) -> None: """ diff --git a/tests/gx_test.py b/tests/gx_test.py index ad511d82..6478eb48 100644 --- a/tests/gx_test.py +++ b/tests/gx_test.py @@ -42,9 +42,8 @@ def test_track(self) -> None: 0.000000 0.000000 1.000000 1.000000 """ + g = Track.class_from_string(doc, ns="") - g = GxGeometry() - g.from_string(doc) assert g.geometry.__geo_interface__ == { "type": "LineString", "bbox": (0.0, 0.0, 1.0, 1.0), From 88f2cdf1964a55858e276fcd8565f05e8b481a2b Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sun, 15 Oct 2023 16:59:34 +0100 Subject: [PATCH 5/9] Write MultiTrack --- fastkml/gx.py | 87 +++++++++++++++++++++++++++++++++++++-- tests/gx_test.py | 105 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 4 deletions(-) diff --git a/fastkml/gx.py b/fastkml/gx.py index a954f790..1e8cb363 100644 --- a/fastkml/gx.py +++ b/fastkml/gx.py @@ -252,9 +252,9 @@ def __init__( raise ValueError("Cannot specify both geometry and track_items") if geometry: track_items = linestring_to_track_items(geometry) - self.track_items = track_items - if track_items: + elif track_items: geometry = track_items_to_geometry(track_items) + self.track_items = track_items super().__init__( ns=ns, id=id, @@ -264,7 +264,6 @@ def __init__( altitude_mode=altitude_mode, geometry=geometry, ) - self.track_items def __repr__(self) -> str: return ( @@ -344,4 +343,84 @@ def _get_kwargs( return kwargs -__all__ = ["GxGeometry"] +def multilinestring_to_tracks( + multilinestring: geo.MultiLineString, ns: Optional[str] +) -> List[Track]: + return [Track(ns=ns, geometry=linestring) for linestring in multilinestring.geoms] + + +def tracks_to_geometry(tracks: Sequence[Track]) -> geo.MultiLineString: + return geo.MultiLineString.from_linestrings( + *[cast(geo.LineString, track.geometry) for track in tracks if track.geometry] + ) + + +class MultiTrack(_Geometry): + def __init__( + self, + *, + ns: Optional[str] = None, + id: Optional[str] = None, + target_id: Optional[str] = None, + extrude: Optional[bool] = False, + tessellate: Optional[bool] = False, + altitude_mode: Optional[AltitudeMode] = None, + geometry: Optional[geo.MultiLineString] = None, + tracks: Optional[Sequence[Track]] = None, + interpolate: Optional[bool] = None, + ) -> None: + if geometry and tracks: + raise ValueError("Cannot specify both geometry and track_items") + if geometry: + tracks = multilinestring_to_tracks(geometry, ns=ns) + elif tracks: + geometry = tracks_to_geometry(tracks) + self.tracks = tracks + self.interpolate = interpolate + super().__init__( + ns=ns, + id=id, + target_id=target_id, + extrude=extrude, + tessellate=tessellate, + altitude_mode=altitude_mode, + geometry=geometry, + ) + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(" + f"ns={self.ns!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"tracks={self.tracks!r}" + f"interpolate={self.interpolate!r}" + ")" + ) + + def etree_element( + self, + precision: Optional[int] = None, + verbosity: Verbosity = Verbosity.normal, + name_spaces: Optional[Dict[str, str]] = None, + ) -> Element: + self.__name__ = self.__class__.__name__ + element = super().etree_element(precision=precision, verbosity=verbosity) + if self.interpolate is not None: + i_element = cast( + Element, + config.etree.SubElement( # type: ignore[attr-defined] + element, f"{self.ns}interpolate" + ), + ) + i_element.text = str(int(self.interpolate)) + for track in self.tracks: + element.append( + track.etree_element( + precision=precision, verbosity=verbosity, name_spaces=name_spaces + ) + ) + return element diff --git a/tests/gx_test.py b/tests/gx_test.py index 6478eb48..afbd220b 100644 --- a/tests/gx_test.py +++ b/tests/gx_test.py @@ -23,6 +23,7 @@ from fastkml.enums import AltitudeMode from fastkml.gx import Angle from fastkml.gx import GxGeometry +from fastkml.gx import MultiTrack from fastkml.gx import Track from fastkml.gx import TrackItem from tests.base import Lxml @@ -253,6 +254,106 @@ def test_track_from_str(self) -> None: ) +class TestMultiTrack(StdLibrary): + def test_from_multilinestring(self) -> None: + lines = geo.MultiLineString( + ([(0, 0), (1, 1), (1, 2), (2, 2)], [[0.0, 0.0], [1.0, 2.0]]), + ) + + mt = MultiTrack(geometry=lines, ns="") + + assert repr(mt) == repr( + MultiTrack( + ns="", + id=None, + target_id=None, + extrude=False, + tessellate=False, + altitude_mode=None, + tracks=[ + Track( + ns="", + id=None, + target_id=None, + extrude=False, + tessellate=False, + altitude_mode=None, + track_items=[ + TrackItem(when=None, coord=geo.Point(0, 0), angle=None), + TrackItem(when=None, coord=geo.Point(1, 1), angle=None), + TrackItem(when=None, coord=geo.Point(1, 2), angle=None), + TrackItem(when=None, coord=geo.Point(2, 2), angle=None), + ], + ), + Track( + ns="", + id=None, + target_id=None, + extrude=False, + tessellate=False, + altitude_mode=None, + track_items=[ + TrackItem(when=None, coord=geo.Point(0.0, 0.0), angle=None), + TrackItem(when=None, coord=geo.Point(1.0, 2.0), angle=None), + ], + ), + ], + ) + ) + + def test_multitrack(self) -> None: + track = MultiTrack( + ns="", + interpolate=True, + tracks=[ + Track( + ns="", + track_items=[ + TrackItem(when=None, coord=geo.Point(0, 0), angle=None), + TrackItem(when=None, coord=geo.Point(1, 1), angle=None), + TrackItem(when=None, coord=geo.Point(1, 2), angle=None), + TrackItem(when=None, coord=geo.Point(2, 2), angle=None), + ], + ), + Track( + ns="", + track_items=[ + TrackItem( + when=datetime.datetime( + 2010, 5, 28, 2, 2, 55, tzinfo=tzutc() + ), + coord=geo.Point(-122.203451, 37.374706, 141.800003), + angle=Angle(heading=1.0, tilt=2.0, roll=3.0), + ), + TrackItem( + when=datetime.datetime( + 2010, 5, 28, 2, 2, 56, tzinfo=tzutc() + ), + coord=geo.Point(-122.203329, 37.37478, 141.199997), + angle=Angle(heading=1.0, tilt=2.0, roll=3.0), + ), + ], + ), + ], + ) + + assert track.geometry == geo.MultiLineString( + ( + ((0, 0), (1, 1), (1, 2), (2, 2)), + ( + (-122.203451, 37.374706, 141.800003), + (-122.203329, 37.37478, 141.199997), + ), + ) + ) + assert "MultiTrack>" in track.to_string() + assert "interpolate>1" in track.to_string() + assert "coord>" in track.to_string() + assert "angles>" in track.to_string() + assert "when>" in track.to_string() + + class TestLxml(Lxml, TestStdLibrary): """Test with lxml.""" @@ -263,3 +364,7 @@ class TestLxmlGetGxGeometry(Lxml, TestGetGxGeometry): class TestLxmlTrack(Lxml, TestTrack): """Test with lxml.""" + + +class TestLxmlMultiTrack(Lxml, TestMultiTrack): + """Test with lxml.""" From 018007caaf9dfb59790f343e0c8b6a4150bf39b3 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sun, 15 Oct 2023 18:32:09 +0100 Subject: [PATCH 6/9] Remove old Geometry and GxGeometry classes --- fastkml/__init__.py | 2 - fastkml/geometry.py | 451 ------------------------------------------ fastkml/gx.py | 120 ++++++----- fastkml/kml.py | 69 +++++-- tests/data_test.py | 2 - tests/gx_test.py | 79 +++++++- tests/oldunit_test.py | 361 +-------------------------------- 7 files changed, 181 insertions(+), 903 deletions(-) diff --git a/fastkml/__init__.py b/fastkml/__init__.py index cbe4b4c2..4f603f57 100644 --- a/fastkml/__init__.py +++ b/fastkml/__init__.py @@ -34,7 +34,6 @@ from fastkml.data import ExtendedData from fastkml.data import Schema from fastkml.data import SchemaData -from fastkml.gx import GxGeometry from fastkml.kml import KML from fastkml.kml import Document from fastkml.kml import Folder @@ -66,7 +65,6 @@ "TimeStamp", "ExtendedData", "Data", - "GxGeometry", "Schema", "SchemaData", "StyleUrl", diff --git a/fastkml/geometry.py b/fastkml/geometry.py index 435033f2..baff7f39 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -17,7 +17,6 @@ import contextlib import logging import re -import warnings from functools import partial from typing import Any from typing import Dict @@ -26,10 +25,8 @@ from typing import Sequence from typing import Union from typing import cast -from typing import no_type_check import pygeoif.geometry as geo -from pygeoif.factories import shape from pygeoif.types import PointType from fastkml import config @@ -49,454 +46,6 @@ AnyGeometryType = Union[GeometryType, MultiGeometryType] -class Geometry(_BaseObject): - """Deprecated: to be replaced by the subclasses of _Geometry.""" - - geometry = None - extrude = False - tessellate = False - altitude_mode = Optional[AltitudeMode] - - def __init__( - self, - ns: Optional[str] = None, - id: Optional[str] = None, - target_id: Optional[str] = None, - geometry: Optional[Any] = None, - extrude: bool = False, - tessellate: bool = False, - altitude_mode: Optional[AltitudeMode] = None, - ) -> None: - """ - geometry: a geometry that implements the __geo_interface__ convention - - extrude: boolean --> Specifies whether to connect the feature to - the ground with a line. To extrude a Feature, the value for - 'altitudeMode' must be either relativeToGround, relativeToSeaFloor, - or absolute. The feature is extruded toward the center of the - Earth's sphere. - tessellate: boolean --> Specifies whether to allow the LineString - to follow the terrain. To enable tessellation, the altitude - mode must be clampToGround or clampToSeaFloor. Very large - LineStrings should enable tessellation so that they follow - the curvature of the earth (otherwise, they may go underground - and be hidden). - This field is not used by Polygon or Point. To allow a Polygon - to follow the terrain (that is, to enable tessellation) specify - an altitude mode of clampToGround or clampToSeaFloor. - altitudeMode: [clampToGround, relativeToGround, absolute] --> - Specifies how altitude components in the element - are interpreted. Possible values are - clampToGround - (default) Indicates to ignore an altitude - specification. - relativeToGround - Sets the altitude of the element relative - to the actual ground elevation of a particular location. - For example, if the ground elevation of a location is - exactly at sea level and the altitude for a point is - set to 9 meters, then the elevation for the icon of a - point placemark elevation is 9 meters with this mode. - However, if the same coordinate is set over a location - where the ground elevation is 10 meters above sea level, - then the elevation of the coordinate is 19 meters. - A typical use of this mode is for placing telephone - poles or a ski lift. - absolute - Sets the altitude of the coordinate relative to - sea level, regardless of the actual elevation of the - terrain beneath the element. For example, if you set - the altitude of a coordinate to 10 meters with an - absolute altitude mode, the icon of a point placemark - will appear to be at ground level if the terrain beneath - is also 10 meters above sea level. If the terrain is - 3 meters above sea level, the placemark will appear - elevated above the terrain by 7 meters. A typical use - of this mode is for aircraft placement. - - https://developers.google.com/kml/documentation/kmlreference#geometry - """ - warnings.warn( - "Geometry is deprecated. Use the subclasses of _Geometry instead.", - DeprecationWarning, - ) - super().__init__(ns=ns, id=id, target_id=target_id) - self.extrude = extrude - self.tessellate = tessellate - self.altitude_mode = altitude_mode # type: ignore[assignment] - if geometry: - if isinstance( - geometry, - ( - geo.Point, - geo.LineString, - geo.Polygon, - geo.MultiPoint, - geo.MultiLineString, - geo.MultiPolygon, - geo.LinearRing, - geo.GeometryCollection, - ), - ): - self.geometry = geometry - else: - self.geometry = shape(geometry) - - # write kml - - def _set_altitude_mode(self, element: Element) -> None: - if self.altitude_mode: - # assert self.altitude_mode in [ - # "clampToGround", - # "relativeToGround", - # "absolute", - # ] - # if self.altitude_mode != "clampToGround": - am_element = config.etree.SubElement( # type: ignore[attr-defined] - element, f"{self.ns}altitudeMode" - ) - am_element.text = self.altitude_mode.value # type: ignore[attr-defined] - - def _set_extrude(self, element: Element) -> None: - if self.extrude and self.altitude_mode in [ # type: ignore[comparison-overlap] - AltitudeMode.relative_to_ground, - AltitudeMode.relative_to_sea_floor, - AltitudeMode.absolute, - ]: - et_element = cast( - Element, - config.etree.SubElement( # type: ignore[attr-defined] - element, f"{self.ns}extrude" - ), - ) - et_element.text = "1" - - def _etree_coordinates( - self, - coordinates: Sequence[PointType], - ) -> Element: - element = cast( - Element, - config.etree.Element(f"{self.ns}coordinates"), # type: ignore[attr-defined] - ) - if len(coordinates[0]) == 2: - tuples = (f"{c[0]:f},{c[1]:f}" for c in coordinates) - elif len(coordinates[0]) == 3: - tuples = ( - f"{c[0]:f},{c[1]:f},{c[2]:f}" for c in coordinates # type: ignore[misc] - ) - else: - raise ValueError("Invalid dimensions") - element.text = " ".join(tuples) - return element - - def _etree_point(self, point: geo.Point) -> Element: - element = self._extrude_and_altitude_mode("Point") - coords = list(point.coords) - element.append(self._etree_coordinates(coords)) - return element - - def _etree_linestring(self, linestring: geo.LineString) -> Element: - element = self._extrude_and_altitude_mode("LineString") - if ( - self.tessellate - and self.altitude_mode - in [ # type: ignore[comparison-overlap] - "clampToGround", - "clampToSeaFloor", - ] - ): - ts_element = config.etree.SubElement( # type: ignore[attr-defined] - element, f"{self.ns}tessellate" - ) - ts_element.text = "1" - coords = list(linestring.coords) - element.append(self._etree_coordinates(coords)) - return element - - def _etree_linearring(self, linearring: geo.LinearRing) -> Element: - element = self._extrude_and_altitude_mode("LinearRing") - coords = list(linearring.coords) - element.append(self._etree_coordinates(coords)) - return element - - def _etree_polygon(self, polygon: geo.Polygon) -> Element: - element = self._extrude_and_altitude_mode("Polygon") - outer_boundary = cast( - Element, - config.etree.SubElement( # type: ignore[attr-defined] - element, - f"{self.ns}outerBoundaryIs", - ), - ) - outer_boundary.append(self._etree_linearring(polygon.exterior)) - for ib in polygon.interiors: - inner_boundary = cast( - Element, - config.etree.SubElement( # type: ignore[attr-defined] - element, - f"{self.ns}innerBoundaryIs", - ), - ) - inner_boundary.append(self._etree_linearring(ib)) - return element - - def _extrude_and_altitude_mode(self, kml_geometry: str) -> Element: - result = cast( - Element, - config.etree.Element( # type: ignore[attr-defined] - f"{self.ns}{kml_geometry}" - ), - ) - self._set_extrude(result) - self._set_altitude_mode(result) - return result - - def _etree_multipoint(self, points: geo.MultiPoint) -> Element: - element = cast( - Element, - config.etree.Element( # type: ignore[attr-defined] - f"{self.ns}MultiGeometry" - ), - ) - for point in points.geoms: - element.append(self._etree_point(point)) - return element - - def _etree_multilinestring(self, linestrings: geo.MultiLineString) -> Element: - element = cast( - Element, - config.etree.Element( # type: ignore[attr-defined] - f"{self.ns}MultiGeometry" - ), - ) - for linestring in linestrings.geoms: - element.append(self._etree_linestring(linestring)) - return element - - def _etree_multipolygon(self, polygons: geo.MultiPolygon) -> Element: - element = cast( - Element, - config.etree.Element( # type: ignore[attr-defined] - f"{self.ns}MultiGeometry" - ), - ) - for polygon in polygons.geoms: - element.append(self._etree_polygon(polygon)) - return element - - def _etree_collection(self, features: geo.GeometryCollection) -> Element: - element = cast( - Element, - config.etree.Element( # type: ignore[attr-defined] - f"{self.ns}MultiGeometry" - ), - ) - for feature in features.geoms: - if feature.geom_type == "Point": - element.append(self._etree_point(cast(geo.Point, feature))) - elif feature.geom_type == "LinearRing": - element.append(self._etree_linearring(cast(geo.LinearRing, feature))) - elif feature.geom_type == "LineString": - element.append(self._etree_linestring(cast(geo.LineString, feature))) - elif feature.geom_type == "Polygon": - element.append(self._etree_polygon(cast(geo.Polygon, feature))) - else: - raise ValueError("Illegal geometry type.") - return element - - def etree_element( - self, - precision: Optional[int] = None, - verbosity: Verbosity = Verbosity.normal, - ) -> Element: - warnings.warn( - "Geometry.etree_element is deprecated. " - "Use the subclasses of _Geometry instead.", - DeprecationWarning, - ) - if isinstance(self.geometry, geo.Point): - return self._etree_point(self.geometry) - elif isinstance(self.geometry, geo.LinearRing): - return self._etree_linearring(self.geometry) - elif isinstance(self.geometry, geo.LineString): - return self._etree_linestring(self.geometry) - elif isinstance(self.geometry, geo.Polygon): - return self._etree_polygon(self.geometry) - elif isinstance(self.geometry, geo.MultiPoint): - return self._etree_multipoint(self.geometry) - elif isinstance(self.geometry, geo.MultiLineString): - return self._etree_multilinestring(self.geometry) - elif isinstance(self.geometry, geo.MultiPolygon): - return self._etree_multipolygon(self.geometry) - elif isinstance(self.geometry, geo.GeometryCollection): - return self._etree_collection(self.geometry) - else: - raise ValueError("Illegal geometry type.") - - # read kml - - def _get_geometry_spec(self, element: Element) -> None: - extrude = element.find(f"{self.ns}extrude") - if extrude is not None: - try: - et = bool(int(extrude.text.strip())) - except ValueError: - et = False - self.extrude = et - else: - self.extrude = False - tessellate = element.find(f"{self.ns}tessellate") - if tessellate is not None: - try: - te = bool(int(tessellate.text.strip())) - except ValueError: - te = False - self.tessellate = te - else: - self.tessellate = False - altitude_mode = element.find(f"{self.ns}altitudeMode") - if altitude_mode is not None: - am = altitude_mode.text.strip() - try: - self.altitude_mode = AltitudeMode(am) # type: ignore[assignment] - except ValueError: - self.altitude_mode = None # type: ignore[assignment] - else: - self.altitude_mode = None # type: ignore[assignment] - - @no_type_check - def _get_coordinates( - self, element: Element, strict: bool = False - ) -> List[PointType]: - coordinates = element.find(f"{self.ns}coordinates") - if coordinates is not None: - # https://developers.google.com/kml/documentation/kmlreference#coordinates - # 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. Clean up badly formatted tuples by stripping - # space following commas. - latlons = re.sub(r", +", ",", coordinates.text.strip()).split() - return [ - cast(PointType, tuple(float(c) for c in latlon.split(","))) - for latlon in latlons - ] - - def _get_linear_ring(self, element: Element) -> Optional[geo.LinearRing]: - # LinearRing in polygon - lr = element.find(f"{self.ns}LinearRing") - if lr is not None: - coords = self._get_coordinates(lr) - return geo.LinearRing(coords) - return None - - @no_type_check - def _get_geometry(self, element: Element) -> Optional[GeometryType]: - # Point, LineString, - # Polygon, LinearRing - if element.tag == f"{self.ns}Point": - coords = self._get_coordinates(element) - self._get_geometry_spec(element) - return geo.Point.from_coordinates(coords) - if element.tag == f"{self.ns}LineString": - coords = self._get_coordinates(element) - self._get_geometry_spec(element) - return geo.LineString(coords) - if element.tag == f"{self.ns}Polygon": - self._get_geometry_spec(element) - outer_boundary = element.find(f"{self.ns}outerBoundaryIs") - ob = self._get_linear_ring(outer_boundary) - if not ob: - return None - inner_boundaries = element.findall(f"{self.ns}innerBoundaryIs") - ibs = [ - self._get_linear_ring(inner_boundary) - for inner_boundary in inner_boundaries - ] - return geo.Polygon.from_linear_rings(ob, *[b for b in ibs if b]) - if element.tag == f"{self.ns}LinearRing": - coords = self._get_coordinates(element) - self._get_geometry_spec(element) - return geo.LinearRing(coords) - return None - - @no_type_check - def _get_multigeometry(self, element: Element) -> Optional[MultiGeometryType]: - # MultiGeometry - geoms: List[Union[AnyGeometryType, None]] = [] - if element.tag == f"{self.ns}MultiGeometry": - multigeometries = element.findall(f"{self.ns}MultiGeometry") - for multigeometry in multigeometries: - geom = Geometry(ns=self.ns) - geom.from_element(multigeometry) - geoms.append(geom.geometry) - points = element.findall(f"{self.ns}Point") - for point in points: - self._get_geometry_spec(point) - geoms.append(geo.Point.from_coordinates(self._get_coordinates(point))) - linestrings = element.findall(f"{self.ns}LineString") - for ls in linestrings: - self._get_geometry_spec(ls) - geoms.append(geo.LineString(self._get_coordinates(ls))) - polygons = element.findall(f"{self.ns}Polygon") - for polygon in polygons: - self._get_geometry_spec(polygon) - outer_boundary = polygon.find(f"{self.ns}outerBoundaryIs") - ob = self._get_linear_ring(outer_boundary) - if not ob: - continue - inner_boundaries = polygon.findall(f"{self.ns}innerBoundaryIs") - inner_bs = [ - self._get_linear_ring(inner_boundary) - for inner_boundary in inner_boundaries - ] - ibs: List[geo.LinearRing] = [ib for ib in inner_bs if ib] - geoms.append(geo.Polygon.from_linear_rings(ob, *ibs)) - linearings = element.findall(f"{self.ns}LinearRing") - if linearings: - for lr in linearings: - self._get_geometry_spec(lr) - geoms.append(geo.LinearRing(self._get_coordinates(lr))) - clean_geoms: List[AnyGeometryType] = [g for g in geoms if g] - if clean_geoms: - geom_types = {geom.geom_type for geom in clean_geoms} - if len(geom_types) > 1: - return geo.GeometryCollection( - clean_geoms, - ) - if "Point" in geom_types: - return geo.MultiPoint.from_points( - *clean_geoms, - ) - elif "LineString" in geom_types: - return geo.MultiLineString.from_linestrings( - *clean_geoms, - ) - elif "Polygon" in geom_types: - return geo.MultiPolygon.from_polygons( - *clean_geoms, - ) - elif "LinearRing" in geom_types: - return geo.GeometryCollection( - clean_geoms, - ) - return None - - def from_element(self, element: Element) -> None: - warnings.warn( - "Geometry.from_element is deprecated. " - "Use the subclasses of _Geometry instead.", - DeprecationWarning, - ) - geom = self._get_geometry(element) - if geom is not None: - self.geometry = geom - else: - mgeom = self._get_multigeometry(element) - if mgeom is not None: - self.geometry = mgeom - else: - logger.warning("No geometries found") - - class _Geometry(_BaseObject): """Baseclass with common methods for all geometry objects. diff --git a/fastkml/gx.py b/fastkml/gx.py index 1e8cb363..892850e9 100644 --- a/fastkml/gx.py +++ b/fastkml/gx.py @@ -75,6 +75,7 @@ The complete XML schema for elements in this extension namespace is located at http://developers.google.com/kml/schema/kml22gx.xsd. """ +import contextlib import datetime import logging from dataclasses import dataclass @@ -85,79 +86,20 @@ from typing import List from typing import Optional from typing import Sequence -from typing import Union from typing import cast import dateutil.parser import pygeoif.geometry as geo -from pygeoif.types import PointType import fastkml.config as config from fastkml.enums import AltitudeMode from fastkml.enums import Verbosity -from fastkml.geometry import Geometry from fastkml.geometry import _Geometry from fastkml.types import Element logger = logging.getLogger(__name__) -class GxGeometry(Geometry): - def __init__( - self, - ns: Optional[str] = None, - id: Optional[str] = None, - ) -> None: - """ - gxgeometry: a read-only subclass of geometry supporting gx: features, - like gx:Track - """ - super().__init__(ns, id) - self.ns = config.GXNS if ns is None else ns - - def _get_geometry(self, element: Element) -> Optional[geo.LineString]: - # Track - if element.tag == (f"{self.ns}Track"): - coords = self._get_coordinates(element) - self._get_geometry_spec(element) - return geo.LineString( - coords, - ) - return None - - def _get_multigeometry( - self, - element: Element, - ) -> Union[geo.MultiLineString, geo.GeometryCollection, None]: - # MultiTrack - geoms = [] - if element.tag == (f"{self.ns}MultiTrack"): - tracks = element.findall(f"{self.ns}Track") - for track in tracks: - self._get_geometry_spec(track) - geoms.append( - geo.LineString( - self._get_coordinates(track), - ) - ) - - geom_types = {geom.geom_type for geom in geoms} - if len(geom_types) > 1: - return geo.GeometryCollection(geoms) - if "LineString" in geom_types: - return geo.MultiLineString.from_linestrings(*geoms) - return None - - def _get_coordinates(self, element: Element) -> List[PointType]: - coordinates = element.findall(f"{self.ns}coord") - if coordinates is not None: - return [ - cast(PointType, tuple(float(c) for c in coord.text.strip().split())) - for coord in coordinates - ] - return [] # type: ignore[unreachable] - - @dataclass(frozen=True) class Angle: """ @@ -322,11 +264,10 @@ def track_items_kwargs_from_element( angles.append(Angle(*[float(a) for a in angle.text.strip().split()])) else: angles.append(None) - track_items = [ + return [ TrackItem(when=when, coord=coord, angle=angle) for when, coord, angle in zip_longest(time_stamps, coords, angles) ] - return track_items @classmethod def _get_kwargs( @@ -396,7 +337,7 @@ def __repr__(self) -> str: f"extrude={self.extrude!r}, " f"tessellate={self.tessellate!r}, " f"altitude_mode={self.altitude_mode}, " - f"tracks={self.tracks!r}" + f"tracks={self.tracks!r}, " f"interpolate={self.interpolate!r}" ")" ) @@ -417,10 +358,63 @@ def etree_element( ), ) i_element.text = str(int(self.interpolate)) - for track in self.tracks: + for track in self.tracks or []: element.append( track.etree_element( precision=precision, verbosity=verbosity, name_spaces=name_spaces ) ) return element + + @classmethod + def _get_interpolate( + cls, + *, + ns: str, + element: Element, + strict: bool, + ) -> Optional[bool]: + interpolate = element.find(f"{ns}interpolate") + if interpolate is None: + return None + with contextlib.suppress(ValueError): + return bool(int(interpolate.text.strip())) + return None + + @classmethod + def _get_track_kwargs_from_element( + cls, + *, + ns: str, + element: Element, + strict: bool, + ) -> List[Track]: + return [ + cast( + Track, + Track.class_from_element( + ns=ns, + element=track, + strict=strict, + ), + ) + for track in element.findall(f"{ns}Track") + if track is not None + ] + + @classmethod + def _get_kwargs( + cls, + *, + ns: str, + element: Element, + strict: bool, + ) -> Dict[str, Any]: + kwargs = super()._get_kwargs(ns=ns, element=element, strict=strict) + kwargs["interpolate"] = cls._get_interpolate( + ns=ns, element=element, strict=strict + ) + kwargs["tracks"] = cls._get_track_kwargs_from_element( + ns=config.GXNS, element=element, strict=strict + ) + return kwargs diff --git a/fastkml/kml.py b/fastkml/kml.py index a0f07eb3..848f1614 100644 --- a/fastkml/kml.py +++ b/fastkml/kml.py @@ -38,7 +38,7 @@ from fastkml.data import ExtendedData from fastkml.data import Schema from fastkml.enums import Verbosity -from fastkml.geometry import Geometry +from fastkml.geometry import AnyGeometryType from fastkml.geometry import LinearRing from fastkml.geometry import LineString from fastkml.geometry import MultiGeometry @@ -57,6 +57,16 @@ logger = logging.getLogger(__name__) +KmlGeometry = Union[ + Point, + LineString, + LinearRing, + Polygon, + MultiGeometry, + gx.MultiTrack, + gx.Track, +] + class _Feature(TimeMixin, _BaseObject): """ @@ -1645,16 +1655,35 @@ class Placemark(_Feature): __name__ = "Placemark" _geometry = None - @property - def geometry(self): - return self._geometry.geometry + def __init__( + self, + ns: Optional[str] = None, + id: Optional[str] = None, + target_id: Optional[str] = None, + name: Optional[str] = None, + description: Optional[str] = None, + styles: Optional[List[Style]] = None, + style_url: Optional[str] = None, + extended_data: None = None, + geometry: Optional[KmlGeometry] = None, + ) -> None: + super().__init__( + ns=ns, + id=id, + target_id=target_id, + name=name, + description=description, + styles=styles, + style_url=style_url, + extended_data=extended_data, + ) + self._geometry = geometry - @geometry.setter - def geometry(self, geometry): - if isinstance(geometry, Geometry): - self._geometry = geometry - else: - self._geometry = Geometry(ns=self.ns, geometry=geometry) + @property + def geometry(self) -> Optional[AnyGeometryType]: + if self._geometry is not None: + return self._geometry.geometry + return None def from_element(self, element: Element, strict=False) -> None: super().from_element(element) @@ -1692,8 +1721,6 @@ def from_element(self, element: Element, strict=False) -> None: return multigeometry = element.find(f"{self.ns}MultiGeometry") if multigeometry is not None: - geom = Geometry(ns=self.ns) - geom.from_element(multigeometry) self._geometry = MultiGeometry.class_from_element( ns=self.ns, element=multigeometry, @@ -1702,15 +1729,19 @@ def from_element(self, element: Element, strict=False) -> None: return track = element.find(f"{self.ns}Track") if track is not None: - geom = gx.GxGeometry(ns=gx.NS) - geom.from_element(track) - self._geometry = geom + self._geometry = gx.Track.class_from_element( + ns=config.GXNS, + element=track, + strict=strict, + ) return multitrack = element.find(f"{self.ns}MultiTrack") - if line is not None: - geom = gx.GxGeometry(ns=gx.NS) - geom.from_element(multitrack) - self._geometry = geom + if multitrack is not None: + self._geometry = gx.MultiTrack.class_from_element( + ns=config.GXNS, + element=multitrack, + strict=strict, + ) return logger.warning("No geometries found") logger.debug("Problem with element: %", config.etree.tostring(element)) diff --git a/tests/data_test.py b/tests/data_test.py index 37d69996..adad5f7b 100644 --- a/tests/data_test.py +++ b/tests/data_test.py @@ -15,7 +15,6 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Test the gx classes.""" import pytest -from pygeoif import Point import fastkml as kml from fastkml import data @@ -77,7 +76,6 @@ def test_untyped_extended_data(self) -> None: k = kml.KML(ns=ns) p = kml.Placemark(ns, "id", "name", "description") - p.geometry = Point(0.0, 0.0, 0.0) p.extended_data = kml.ExtendedData( ns=ns, elements=[ diff --git a/tests/gx_test.py b/tests/gx_test.py index afbd220b..eebae36b 100644 --- a/tests/gx_test.py +++ b/tests/gx_test.py @@ -22,7 +22,6 @@ from fastkml.enums import AltitudeMode from fastkml.gx import Angle -from fastkml.gx import GxGeometry from fastkml.gx import MultiTrack from fastkml.gx import Track from fastkml.gx import TrackItem @@ -56,23 +55,87 @@ def test_multitrack(self) -> None: - 2020-01-01T00:00:00Z - 2020-01-01T00:10:00Z + 2020-01-01T00:00:00Z + 2020-01-01T00:10:00Z 0.000000 0.000000 1.000000 0.000000 - 2020-01-01T00:10:00Z - 2020-01-01T00:20:00Z + 2020-01-01T00:10:00Z + 2020-01-01T00:20:00Z 0.000000 1.000000 1.000000 1.000000 """ - g = GxGeometry() - g.from_string(doc) - assert len(g.geometry) == 2 + mt = MultiTrack.class_from_string(doc, ns="") + + assert mt.geometry == geo.MultiLineString( + (((0.0, 0.0), (1.0, 0.0)), ((0.0, 1.0), (1.0, 1.0))) + ) + assert "when>" in mt.to_string() + assert repr(mt) == repr( + MultiTrack( + ns="", + id="", + target_id="", + extrude=None, + tessellate=None, + altitude_mode=None, + tracks=[ + Track( + ns="{http://www.google.com/kml/ext/2.2}", + id="", + target_id="", + extrude=None, + tessellate=None, + altitude_mode=None, + track_items=[ + TrackItem( + when=datetime.datetime( + 2020, 1, 1, 0, 0, tzinfo=tzutc() + ), + coord=geo.Point(0.0, 0.0), + angle=None, + ), + TrackItem( + when=datetime.datetime( + 2020, 1, 1, 0, 10, tzinfo=tzutc() + ), + coord=geo.Point(1.0, 0.0), + angle=None, + ), + ], + ), + Track( + ns="{http://www.google.com/kml/ext/2.2}", + id="", + target_id="", + extrude=None, + tessellate=None, + altitude_mode=None, + track_items=[ + TrackItem( + when=datetime.datetime( + 2020, 1, 1, 0, 10, tzinfo=tzutc() + ), + coord=geo.Point(0.0, 1.0), + angle=None, + ), + TrackItem( + when=datetime.datetime( + 2020, 1, 1, 0, 20, tzinfo=tzutc() + ), + coord=geo.Point(1.0, 1.0), + angle=None, + ), + ], + ), + ], + interpolate=None, + ) + ) class TestTrack(StdLibrary): diff --git a/tests/oldunit_test.py b/tests/oldunit_test.py index dde9bc86..ee4f2c9d 100644 --- a/tests/oldunit_test.py +++ b/tests/oldunit_test.py @@ -16,13 +16,8 @@ import xml.etree.ElementTree import pytest -from pygeoif.geometry import GeometryCollection from pygeoif.geometry import LinearRing -from pygeoif.geometry import LineString -from pygeoif.geometry import MultiLineString from pygeoif.geometry import MultiPoint -from pygeoif.geometry import MultiPolygon -from pygeoif.geometry import Point from pygeoif.geometry import Polygon from fastkml import atom @@ -30,7 +25,6 @@ from fastkml import config from fastkml import kml from fastkml import styles -from fastkml.geometry import Geometry try: import lxml @@ -158,9 +152,9 @@ def test_placemark(self) -> None: ns = "{http://www.opengis.net/kml/2.2}" # noqa: FS003 k = kml.KML(ns=ns) p = kml.Placemark(ns, "id", "name", "description") - p.geometry = Point(0.0, 0.0, 0.0) + # XXX p.geometry = Point(0.0, 0.0, 0.0) p2 = kml.Placemark(ns, "id2", "name2", "description2") - p2.geometry = LineString([(0, 0, 0), (1, 1, 1)]) + # XXX p2.geometry = LineString([(0, 0, 0), (1, 1, 1)]) k.append(p) k.append(p2) assert len(list(k.features())) == 2 @@ -180,7 +174,7 @@ def test_document(self) -> None: f2 = kml.Folder(ns, "id2", "name2", "description2") d.append(f2) p = kml.Placemark(ns, "id", "name", "description") - p.geometry = Polygon([(0, 0, 0), (1, 1, 0), (1, 0, 1)]) + # XXX p.geometry = Polygon([(0, 0, 0), (1, 1, 0), (1, 0, 1)]) p2 = kml.Placemark(ns, "id2", "name2", "description2") # p2 does not have a geometry! f2.append(p) @@ -1247,139 +1241,6 @@ def test_get_style_by_url(self) -> None: assert isinstance(style, styles.StyleMap) -class TestSetGeometry: - def test_point(self) -> None: - p = Point(0, 1) - g = Geometry(geometry=p) - assert g.geometry == p - g = Geometry(geometry=p.__geo_interface__) - assert g.geometry.__geo_interface__ == p.__geo_interface__ - assert "Point" in str(g.to_string()) - assert "coordinates>0.000000,1.000000 None: - line = LineString([(0, 0), (1, 1)]) - g = Geometry(geometry=line) - assert g.geometry == line - assert "LineString" in str(g.to_string()) - assert "coordinates>0.000000,0.000000 1.000000,1.000000 None: - line = LinearRing([(0, 0), (1, 0), (1, 1), (0, 0)]) - g = Geometry(geometry=line) - assert g.geometry == line - assert "LinearRing" in str(g.to_string()) - assert ( - "coordinates>0.000000,0.000000 1.000000,0.000000 1.000000,1.000000 " - "0.000000,0.000000 None: - # without holes - poly = Polygon([(0, 0), (1, 0), (1, 1), (0, 0)]) - g = Geometry(geometry=poly) - assert g.geometry == poly - assert "Polygon" in str(g.to_string()) - assert "outerBoundaryIs" in str(g.to_string()) - assert "innerBoundaryIs" not in str(g.to_string()) - assert "LinearRing" in str(g.to_string()) - assert ( - "coordinates>0.000000,0.000000 1.000000,0.000000 1.000000,1.000000 " - "0.000000,0.0000000.000000,0.000000 1.000000,0.000000 1.000000,1.000000 " - "0.000000,0.000000-1.000000,-1.000000 2.000000,-1.000000 2.000000,2.000000 " - "-1.000000,-1.000000 None: - p0 = Point(0, 1) - p1 = Point(1, 1) - g = Geometry(geometry=MultiPoint.from_points(p0, p1)) - assert "MultiGeometry" in str(g.to_string()) - assert "Point" in str(g.to_string()) - assert "coordinates>0.000000,1.0000001.000000,1.000000 None: - l0 = LineString([(0, 0), (1, 0)]) - l1 = LineString([(0, 1), (1, 1)]) - g = Geometry(geometry=MultiLineString.from_linestrings(l0, l1)) - assert "MultiGeometry" in str(g.to_string()) - assert "LineString" in str(g.to_string()) - assert "coordinates>0.000000,0.000000 1.000000,0.0000000.000000,1.000000 1.000000,1.000000 None: - # with holes - p0 = Polygon( - [(-1, -1), (2, -1), (2, 2), (-1, -1)], [[(0, 0), (1, 0), (1, 1), (0, 0)]] - ) - # without holes - p1 = Polygon([(3, 0), (4, 0), (4, 1), (3, 0)]) - g = Geometry(geometry=MultiPolygon.from_polygons(p0, p1)) - assert "MultiGeometry" in str(g.to_string()) - assert "Polygon" in str(g.to_string()) - assert "outerBoundaryIs" in str(g.to_string()) - assert "innerBoundaryIs" in str(g.to_string()) - assert "LinearRing" in str(g.to_string()) - assert ( - "coordinates>0.000000,0.000000 1.000000,0.000000 1.000000,1.000000 " - "0.000000,0.000000-1.000000,-1.000000 2.000000,-1.000000 2.000000,2.000000 " - "-1.000000,-1.0000003.000000,0.000000 4.000000,0.000000 4.000000,1.000000 " - "3.000000,0.000000 None: - po = Polygon([(3, 0), (4, 0), (4, 1), (3, 0)]) - lr = LinearRing([(0, -1), (1, -1), (1, 1), (0, -1)]) - ls = LineString([(0, 0), (1, 1)]) - p = Point(0, 1) - # geo_if = {'type': 'GeometryCollection', 'geometries': [ - # po.__geo_interface__, p.__geo_interface__, - # ls.__geo_interface__, lr.__geo_interface__]} - g = Geometry(geometry=GeometryCollection([po, p, ls, lr])) - # g1 = Geometry(geometry=shape(geo_if)) - # self.assertEqual(g1.__geo_interface__, g.__geo_interface__) - assert "MultiGeometry" in str(g.to_string()) - assert "Polygon" in str(g.to_string()) - assert "outerBoundaryIs" in str(g.to_string()) - assert "innerBoundaryIs" not in str(g.to_string()) - assert "LinearRing" in str(g.to_string()) - assert ( - "coordinates>3.000000,0.000000 4.000000,0.000000 4.000000,1.000000 " - "3.000000,0.0000000.000000,0.000000 1.000000,1.0000000.000000,1.000000 @@ -1429,222 +1290,6 @@ def test_nested_multigeometry(): class TestGetGeometry: - def test_altitude_mode(self): - doc = """ - 0.000000,1.000000 - clampToGround - """ - - g = Geometry() - assert g.altitude_mode is None - g.from_string(doc) - assert g.altitude_mode.value == "clampToGround" - - def test_extrude(self): - doc = """ - 0.000000,1.000000 - 1 - """ - - g = Geometry() - assert g.extrude is False - g.from_string(doc) - assert g.extrude is True - - def test_tesselate(self): - doc = """ - 0.000000,1.000000 - 1 - """ - - g = Geometry() - assert g.tessellate is False - g.from_string(doc) - assert g.tessellate is True - - def test_point(self): - doc = """ - 0.000000,1.000000 - """ - - g = Geometry() - g.from_string(doc) - assert g.geometry.__geo_interface__ == { - "type": "Point", - "bbox": (0.0, 1.0, 0.0, 1.0), - "coordinates": (0.0, 1.0), - } - - def test_linestring(self): - doc = """ - 0.000000,0.000000 1.000000,1.000000 - """ - - g = Geometry() - g.from_string(doc) - assert g.geometry.__geo_interface__ == { - "type": "LineString", - "bbox": (0.0, 0.0, 1.0, 1.0), - "coordinates": ((0.0, 0.0), (1.0, 1.0)), - } - - def test_linearring(self): - doc = """ - 0.000000,0.000000 1.000000,0.000000 1.000000,1.000000 0.000000,0.000000 - - """ - - g = Geometry() - g.from_string(doc) - assert g.geometry.__geo_interface__ == { - "type": "LinearRing", - "bbox": (0.0, 0.0, 1.0, 1.0), - "coordinates": ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)), - } - - def test_polygon(self): - doc = """ - - - 0.000000,0.000000 1.000000,0.000000 1.000000,1.000000 0.000000,0.000000 - - - - """ - - g = Geometry() - g.from_string(doc) - assert g.geometry.__geo_interface__ == { - "type": "Polygon", - "bbox": (0.0, 0.0, 1.0, 1.0), - "coordinates": (((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)),), - } - doc = """ - - - -1.000000,-1.000000 2.000000,-1.000000 2.000000,2.000000 -1.000000,-1.000000 - - - - - 0.000000,0.000000 1.000000,0.000000 1.000000,1.000000 0.000000,0.000000 - - - - """ - - g.from_string(doc) - assert g.geometry.__geo_interface__ == { - "type": "Polygon", - "bbox": (-1.0, -1.0, 2.0, 2.0), - "coordinates": ( - ((-1.0, -1.0), (2.0, -1.0), (2.0, 2.0), (-1.0, -1.0)), - ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 0.0)), - ), - } - - def test_multipoint(self): - doc = """ - - - 0.000000,1.000000 - - - 1.000000,1.000000 - - - """ - - g = Geometry() - g.from_string(doc) - assert len(g.geometry) == 2 - - def test_multilinestring(self): - doc = """ - - - 0.000000,0.000000 1.000000,0.000000 - - - 0.000000,1.000000 1.000000,1.000000 - - - """ - - g = Geometry() - g.from_string(doc) - assert len(g.geometry) == 2 - - def test_multipolygon(self): - doc = """ - - - - - -1.000000,-1.000000 2.000000,-1.000000 2.000000,2.000000 -1.000000,-1.000000 - - - - - 0.000000,0.000000 1.000000,0.000000 1.000000,1.000000 0.000000,0.000000 - - - - - - - 3.000000,0.000000 4.000000,0.000000 4.000000,1.000000 3.000000,0.000000 - - - - - """ - - g = Geometry() - g.from_string(doc) - assert len(g.geometry) == 2 - - def test_geometrycollection(self): - doc = """ - - - - - 3,0 4,0 4,1 3,0 - - - - - 0.000000,1.000000 - - - 0.000000,0.000000 1.000000,1.000000 - - - 0.0,0.0 1.0,0.0 1.0,1.0 0.0,1.0 0.0,0.0 - - - """ - - g = Geometry() - g.from_string(doc) - assert len(g.geometry) == 4 - doc = """ - - - 3.0,0.0 4.0,0.0 4.0,1.0 3.0,0.0 - - - 0.0,0.0 1.0,0.0 1.0,1.0 0.0,0.0 - - - """ - - g = Geometry() - g.from_string(doc) - assert len(g.geometry) == 2 - assert g.geometry.geom_type == "GeometryCollection" - def test_nested_multigeometry(self): doc = """ From 85cc219875a032cf706ccc0abd95a34c82151538 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sun, 15 Oct 2023 18:47:40 +0100 Subject: [PATCH 7/9] Fix test --- tests/gx_test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/gx_test.py b/tests/gx_test.py index eebae36b..3ba6e795 100644 --- a/tests/gx_test.py +++ b/tests/gx_test.py @@ -55,14 +55,14 @@ def test_multitrack(self) -> None: - 2020-01-01T00:00:00Z - 2020-01-01T00:10:00Z + 2020-01-01T00:00:00+00:00 + 2020-01-01T00:10:00+00:00 0.000000 0.000000 1.000000 0.000000 - 2020-01-01T00:10:00Z - 2020-01-01T00:20:00Z + 2020-01-01T00:10:00+00:00 + 2020-01-01T00:20:00+00:00 0.000000 1.000000 1.000000 1.000000 From c7ea1ed9d67a88cb844ab90ff0f77445970e7a53 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sun, 15 Oct 2023 18:52:49 +0100 Subject: [PATCH 8/9] use to_string not repr --- tests/gx_test.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/gx_test.py b/tests/gx_test.py index 3ba6e795..1acc85ca 100644 --- a/tests/gx_test.py +++ b/tests/gx_test.py @@ -75,8 +75,9 @@ def test_multitrack(self) -> None: (((0.0, 0.0), (1.0, 0.0)), ((0.0, 1.0), (1.0, 1.0))) ) assert "when>" in mt.to_string() - assert repr(mt) == repr( - MultiTrack( + assert ( + mt.to_string() + == MultiTrack( ns="", id="", target_id="", @@ -134,7 +135,7 @@ def test_multitrack(self) -> None: ), ], interpolate=None, - ) + ).to_string() ) From c71625e8f8552452a2aa8a8e0efcbea1cc0975c8 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sun, 15 Oct 2023 18:57:15 +0100 Subject: [PATCH 9/9] don't rely or repr in tests --- tests/gx_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/gx_test.py b/tests/gx_test.py index 1acc85ca..2269bb60 100644 --- a/tests/gx_test.py +++ b/tests/gx_test.py @@ -301,10 +301,9 @@ def test_track_from_str(self) -> None: ), ], ) + track = Track.class_from_string(doc, ns="") - assert repr(track) == repr(expected_track) - assert track.to_string() == expected_track.to_string() assert track.geometry == geo.LineString( ( (-122.207881, 37.371915, 156.0), @@ -316,6 +315,7 @@ def test_track_from_str(self) -> None: (-122.203207, 37.374857, 140.199997), ) ) + assert track.to_string() == expected_track.to_string() class TestMultiTrack(StdLibrary):