Repository: Toblerity/keytree

Issue:
Feature request: support Google extension feature types
Some pro-bono work I'm doing has me converting features like [`gx:Track`](https://developers.google.com/kml/documentation/kmlreference#gx:track) into LineStrings for geojson output. I'm already writing the code to do so and would be happy to make it into a PR for this project, but I wanted to check if the interest is there before I write and submit it.

Discussion:
dericke: @sgillies I think this is finally ready for review. I changed the way the namespaces are used so that the NS for Google's extensions could be used—hope that's acceptable.

Patch:
diff --git a/.gitignore b/.gitignore
index dbf3f82..bb11b69 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@
 dist
 __pycache__
 *.egg-info
+.vscode/
+.pytest_cache/
\ No newline at end of file
diff --git a/keytree/factory.py b/keytree/factory.py
index 86f9275..2ab28ec 100644
--- a/keytree/factory.py
+++ b/keytree/factory.py
@@ -2,40 +2,46 @@
 Factories for features and geometries
 """
 
+from typing import Union
+
 import keytree.compat
-from keytree.model import Geometry, Feature, GEOM_TYPES
+from keytree.model import GEOM_TYPES, NSMAP, Feature, Geometry
 
 
-def feature(element):
-    kmlns = element.tag.split("}")[0][1:]
+def feature(element, kmlns: Union[str, dict] = NSMAP) -> Feature:
     kid = element.attrib.get("id")
-    name = element.findtext("{%s}name" % kmlns)
-    snippet = element.findtext("{%s}Snippet" % kmlns)
-    description = element.findtext("{%s}description" % kmlns)
+    name = element.findtext("kml:name", namespaces=kmlns) or element.findtext(
+        "kml:Name", namespaces=kmlns
+    )
+    snippet = element.findtext("kml:Snippet", namespaces=kmlns)
+    description = element.findtext(
+        "kml:description", namespaces=kmlns
+    ) or element.findtext("kml:Description", namespaces=kmlns)
     for geom_type in GEOM_TYPES:
-        tag = "{%s}%s" % (kmlns, geom_type)
-        geom_element = element.find(tag)
+        geom_element = element.find(geom_type, namespaces=kmlns)
         if geom_element is not None:
-            g = geometry(geom_element)
+            g = geometry(geom_element, kmlns=kmlns)
             return Feature(kid, g, name=name, snippet=snippet, description=description)
     return Feature(kid, None, name=name, snippet=snippet, description=description)
 
 
-def geometry(element):
+def geometry(element, kmlns: dict = NSMAP) -> Geometry:
     tp = element.tag.split("}")
-    kmlns = tp[0][1:]
+    if kmlns is None:
+        kmlns = {"": tp[0][1:]}
     geom_type = tp[1]
+    # geom_type = element.tag
     return geometry_factory[geom_type](element, kmlns)
 
 
-def geometry_Point(element, kmlns):
-    t = element.findtext("{%s}coordinates" % kmlns)
+def geometry_Point(element, kmlns: dict = NSMAP):
+    t = element.findtext("kml:coordinates", namespaces=kmlns)
     tv = t.split(",")
     return Geometry("Point", tuple([float(v) for v in tv]))
 
 
-def geometry_LineString(element, kmlns):
-    text = element.findtext("{%s}coordinates" % kmlns)
+def geometry_LineString(element, kmlns: dict = NSMAP):
+    text = element.findtext("kml:coordinates", namespaces=kmlns)
     ts = text.split()
     coords = []
     for t in ts:
@@ -44,9 +50,18 @@ def geometry_LineString(element, kmlns):
     return Geometry("LineString", tuple(coords))
 
 
-def geometry_Polygon(element, kmlns):
-    shell = element.find("{%s}outerBoundaryIs" % kmlns)
-    text = shell.findtext("*/{%s}coordinates" % kmlns)
+def geometry_Track(element, kmlns: dict = NSMAP):
+    sourcecoords = element.findall("gx:coord", namespaces=kmlns)
+    coords = []
+    for coord in sourcecoords:
+        tv = coord.text.split()
+        coords.append(tuple([float(v) for v in tv]))
+    return Geometry("LineString", tuple(coords))
+
+
+def geometry_Polygon(element, kmlns: dict = NSMAP):
+    shell = element.find("kml:outerBoundaryIs", namespaces=kmlns)
+    text = shell.findtext("*/kml:coordinates", namespaces=kmlns)
     ts = text.split()
     shell_coords = []
     for t in ts:
@@ -55,9 +70,9 @@ def geometry_Polygon(element, kmlns):
     poly_coords = []
     poly_coords.append(tuple(shell_coords))
 
-    holes = element.findall("{%s}innerBoundaryIs" % kmlns)
+    holes = element.findall("kml:innerBoundaryIs", namespaces=kmlns)
     for hole in holes:
-        text = hole.findtext("*/{%s}coordinates" % kmlns)
+        text = hole.findtext("*/kml:coordinates", namespaces=kmlns)
         ts = text.split()
         hole_coords = []
         for t in ts:
@@ -71,5 +86,6 @@ def geometry_Polygon(element, kmlns):
 geometry_factory = {
     "Point": geometry_Point,
     "LineString": geometry_LineString,
+    "Track": geometry_Track,
     "Polygon": geometry_Polygon,
 }
diff --git a/keytree/kml.py b/keytree/kml.py
index 8cc279d..a4a75c2 100644
--- a/keytree/kml.py
+++ b/keytree/kml.py
@@ -3,14 +3,23 @@ Functions and factories for KML elements
 """
 
 import keytree.compat
+from keytree.model import NSMAP
 
 
-def kml_ns(element):
+def kml_ns(element) -> str:
     return element.tag.split("}")[0][1:]
 
 
-def findall_placemarks(element):
-    return element.findall(".//{%s}Placemark" % kml_ns(element))
+def kml_ns_map(element) -> dict:
+    """Returns an nsmap-style dict detected from the given element
+    """
+    return {"": element.tag.split("}")[0][1:]}
+
+
+def findall_placemarks(element, kml_ns: dict = NSMAP) -> list:
+    """Returns a list of Placemark elements that are children of the given element
+    """
+    return element.findall(".//kml:Placemark", namespaces=kml_ns)
 
 
 def element(context, ob, **kw):
diff --git a/keytree/model.py b/keytree/model.py
index c78e17b..103a55d 100644
--- a/keytree/model.py
+++ b/keytree/model.py
@@ -2,7 +2,15 @@
 Geometry classes
 """
 
-GEOM_TYPES = ["Point", "LineString", "LinearRing", "Polygon", "MultiGeometry"]
+GEOM_TYPES = [
+    "kml:Point",
+    "kml:LineString",
+    "kml:LinearRing",
+    "kml:Polygon",
+    "kml:MultiGeometry",
+    "gx:Track",
+    # "gx:MultiTrack",
+]
 
 PROPERTIES_CONTEXT = {
     "name": "http://developers.google.com/kml/documentation/kmlreference#name",
@@ -10,8 +18,15 @@ PROPERTIES_CONTEXT = {
     "description": "http://developers.google.com/kml/documentation/kmlreference#description",
 }
 
+NSMAP = {
+    "": "http://www.opengis.net/kml/2.2",
+    "kml": "http://www.opengis.net/kml/2.2",
+    "gx": "http://www.google.com/kml/ext/2.2",
+    "atom": "http://www.w3.org/2005/Atom",
+}
+
 
-class Dictish(object):
+class Dictish:
     # Provides dict-like access to geo_interface items.
     def __getitem__(self, key):
         return self.__geo_interface__[key]


Test patch:
diff --git a/keytree/tests/test_read.py b/keytree/tests/test_read.py
index 455e820..56a1c63 100644
--- a/keytree/tests/test_read.py
+++ b/keytree/tests/test_read.py
@@ -1,9 +1,9 @@
 import pytest
 
-from keytree import feature
+from keytree import feature, kml
 
 KML = """<?xml version="1.0" encoding="UTF-8"?>
-<kml xmlns="http://www.opengis.net/kml/2.2">
+<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2">
   <Document>
     <Placemark>
       <name>point</name>
@@ -25,6 +25,17 @@ KML = """<?xml version="1.0" encoding="UTF-8"?>
         </coordinates>
       </LineString>
     </Placemark>
+    <Placemark>
+      <name>track</name>
+      <Snippet>Track test</Snippet>
+      <description>Blah, blah, blah</description>
+      <gx:Track>
+        <when>2020-12-13T07:33:02.94Z</when>
+        <when>2020-12-13T07:33:04.04Z</when>
+        <gx:coord>-122.364383 37.824664 0</gx:coord>
+        <gx:coord>-122.364152 37.824322 0</gx:coord>
+      </gx:Track>
+    </Placemark>
     <Placemark>
       <name>polygon</name>
       <Snippet>Polygon test</Snippet>
@@ -59,11 +70,6 @@ KML = """<?xml version="1.0" encoding="UTF-8"?>
 """.encode()  # lxml doesn't like unencoded strings with an encoding declaration
 
 
-#         self.placemarks = self.doc.findall(
-#             "*/{http://www.opengis.net/kml/2.2}Placemark"
-#         )
-
-
 @pytest.fixture
 def doc(etree):
     return etree.fromstring(KML)
@@ -71,13 +77,7 @@ def doc(etree):
 
 @pytest.fixture
 def placemarks(doc):
-    return doc.findall(
-        ".//kml:Placemark",
-        namespaces={
-            "": "http://www.opengis.net/kml/2.2",
-            "kml": "http://www.opengis.net/kml/2.2",
-        },
-    )
+    return kml.findall_placemarks(doc)
 
 
 def test_properties_context(placemarks):
@@ -116,8 +116,22 @@ def test_linestring(placemarks):
     )
 
 
-def test_polygon(placemarks):
+def test_track(placemarks):
     f = feature(placemarks[2])
+    assert f.geometry.type == f["geometry"]["type"] == "LineString"
+    coords0 = f.geometry.coordinates[0]
+    assert coords0 == pytest.approx((-122.36438, 37.82466, 0.0), 5)
+    assert f.properties["name"] == f["properties"]["name"] == "track"
+    assert f.properties["snippet"] == f["properties"]["snippet"] == "Track test"
+    assert (
+        f.properties["description"]
+        == f["properties"]["description"]
+        == "Blah, blah, blah"
+    )
+
+
+def test_polygon(placemarks):
+    f = feature(placemarks[3])
     assert f.geometry.type == f["geometry"]["type"] == "Polygon"
     coords0 = f.geometry.coordinates[0][0]
     assert coords0 == pytest.approx((-122.366278, 37.81884, 30.0), 5)