diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index abe79d57..ff36e7a3 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -40,7 +40,7 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
diff --git a/.github/workflows/run-all-tests.yml b/.github/workflows/run-all-tests.yml
index 14972357..ead106f4 100644
--- a/.github/workflows/run-all-tests.yml
+++ b/.github/workflows/run-all-tests.yml
@@ -9,10 +9,10 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12-dev']
+ python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12']
steps:
- - uses: actions/checkout@v3.2.0
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
@@ -29,10 +29,10 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
+ python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12']
steps:
- - uses: actions/checkout@v3.2.0
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
@@ -46,9 +46,11 @@ jobs:
run: |
pytest tests --cov=fastkml --cov=tests --cov-fail-under=88 --cov-report=xml
- name: "Upload coverage to Codecov"
+ if: ${{ matrix.python-version==3.11 }}
uses: codecov/codecov-action@v3
with:
fail_ci_if_error: true
+ verbose: true
static-tests:
runs-on: ubuntu-latest
@@ -57,7 +59,7 @@ jobs:
python-version: ['3.9']
steps:
- - uses: actions/checkout@v3.2.0
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
@@ -73,7 +75,8 @@ jobs:
run: |
flake8 fastkml examples docs
black --check fastkml examples docs
- yamllint .github/workflows/
+ yamllint .github/
+ yamllint .*.y*ml
- name: Check complexity
run: |
radon cc --min B fastkml
@@ -84,9 +87,9 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- pypy-version: ['pypy-3.7', 'pypy-3.8', 'pypy-3.9']
+ pypy-version: ['pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10']
steps:
- - uses: actions/checkout@v3.2.0
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.pypy-version }}
uses: actions/setup-python@v4
with:
@@ -104,7 +107,7 @@ jobs:
name: Build and publish to PyPI and TestPyPI
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3.2.0
+ - uses: actions/checkout@v4
- name: Set up Python 3.9
uses: actions/setup-python@v4
with:
diff --git a/.gitignore b/.gitignore
index 45d354fa..b0730ad2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,10 +23,14 @@ setup.cfg
# Installer logs
pip-log.txt
-# Unit test / coverage reports
+# Unit/static test / coverage reports
.coverage
+coverage.xml
.tox
+.pytest_cache/
nosetests.xml
+.ruff_cache/
+monkeytype.sqlite3
# Translations
*.mo
@@ -51,6 +55,8 @@ venv
.mypy_cache/
.pyre/
.watchmanconfig
+.pytype/
+
# misc
.dccache
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 76c2e53a..b6edf3c7 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,7 +1,7 @@
---
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
- rev: v4.4.0
+ rev: v4.5.0
hooks:
- id: check-added-large-files
- id: check-docstring-first
@@ -20,8 +20,17 @@ repos:
- id: pretty-format-json
- id: requirements-txt-fixer
- id: trailing-whitespace
+
+ - repo: https://github.com/tox-dev/pyproject-fmt
+ rev: "1.2.0"
+ hooks:
+ - id: pyproject-fmt
+ - repo: https://github.com/abravalheri/validate-pyproject
+ rev: v0.15
+ hooks:
+ - id: validate-pyproject
- repo: https://github.com/ikamensh/flynt/
- rev: "0.77"
+ rev: "1.0.1"
hooks:
- id: flynt
- repo: https://github.com/MarcoGorelli/absolufy-imports
@@ -29,31 +38,51 @@ repos:
hooks:
- id: absolufy-imports
- repo: https://github.com/hakancelikdev/unimport
- rev: 33ead41ee30f1d323a9c2fcfd0114297efbbc4d5
+ rev: 1.0.0
hooks:
- id: unimport
args: [--remove, --include-star-import, --ignore-init, --gitignore]
- repo: https://github.com/asottile/pyupgrade
- rev: v3.3.1
+ rev: v3.15.0
hooks:
- id: pyupgrade
args: ["--py3-plus", "--py37-plus"]
- repo: https://github.com/psf/black
- rev: 22.12.0
+ rev: 23.9.1
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
- rev: 6.0.0
+ rev: 6.1.0
hooks:
- id: flake8
- repo: https://github.com/pycqa/isort
- rev: 5.11.4
+ rev: 5.12.0
hooks:
- id: isort
- - repo: https://github.com/mgedmin/check-manifest
- rev: "0.49"
+ - repo: https://github.com/charliermarsh/ruff-pre-commit
+ rev: 'v0.0.292'
+ hooks:
+ - id: ruff
+ - repo: https://github.com/PyCQA/flake8
+ rev: 6.1.0
hooks:
- - id: check-manifest
+ - id: flake8
+ - repo: https://github.com/pre-commit/pygrep-hooks
+ rev: v1.10.0 # Use the ref you want to point at
+ hooks:
+ - id: python-use-type-annotations
+ - id: python-check-blanket-type-ignore
+ - id: python-check-mock-methods
+ - id: python-no-log-warn
+ - id: python-use-type-annotations
+ - id: rst-backticks
+ - id: rst-directive-colons
+ - id: rst-inline-touching-normal
+ - id: text-unicode-replacement-char
+ # - repo: https://github.com/mgedmin/check-manifest
+ # rev: "0.49"
+ # hooks:
+ # - id: check-manifest
# - repo: https://github.com/Lucas-C/pre-commit-hooks-markup
# rev: v1.0.1
# hooks:
diff --git a/MANIFEST.in b/MANIFEST.in
index c8ecd40b..8ae540fc 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,10 +1,10 @@
include *.rst
recursive-include docs *.txt
recursive-exclude *.pyc *.pyo
-include docs/LICENSE.GPL
-exclude fastkml/.*
+include docs LICENSE.GPL
include *.txt
recursive-include docs *.py
recursive-include docs *.rst
recursive-include docs Makefile
recursive-include tests *.py
+recursive-include schema *.xsd
diff --git a/README.rst b/README.rst
index 09c3ea32..6df6e595 100644
--- a/README.rst
+++ b/README.rst
@@ -30,7 +30,7 @@ Fastkml is continually tested
:target: http://codecov.io/github/cleder/fastkml?branch=main
:alt: codecov.io
-.. image:: https://img.shields.io/badge/code%20style-black-000000.svg
+.. image:: https://img.shields.io/badge/code+style-black-000000.svg
:target: https://github.com/psf/black
:alt: Black
diff --git a/docs/contributing.rst b/docs/contributing.rst
index a2ee904e..319aaa39 100644
--- a/docs/contributing.rst
+++ b/docs/contributing.rst
@@ -15,7 +15,7 @@ especially in the following ways:
Pull Requests
-------------
-Start by submitting a pull request on GitHub against the `develop` branch of the
+Start by submitting a pull request on GitHub against the ``develop`` branch of the
repository. Your pull request should provide a good description of the change
you are making, and/or the bug that you are fixing.
@@ -64,6 +64,15 @@ available.
.. _tox: https://pypi.python.org/pypi/tox
+coverage
+~~~~~~~~
+
+You can also run the tests with coverage_ to see which lines are covered by the
+tests. This is useful for writing new tests to cover any uncovered lines::
+
+ pytest tests --cov=fastkml --cov=tests --cov-report=xml
+
+
pre-commit
~~~~~~~~~~~
diff --git a/docs/index.rst b/docs/index.rst
index 10c07498..45b422b6 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -63,7 +63,7 @@ requirements, namely:
* You can parse any KML snippet, it does not need to be a complete KML
document.
* It is fully tested and actively maintained.
-* Geometries are handled in the `__geo_interface__` standard.
+* Geometries are handled in the ``__geo_interface__`` standard.
* Minimal dependencies, pure Python.
* If available, lxml_ will be used to increase its speed.
diff --git a/examples/UsageExamples.py b/examples/UsageExamples.py
index fa2221c2..552a2d7f 100644
--- a/examples/UsageExamples.py
+++ b/examples/UsageExamples.py
@@ -13,7 +13,6 @@ def print_child_features(element):
if __name__ == "__main__":
-
fname = "KML_Samples.kml"
k = kml.KML()
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/atom.py b/fastkml/atom.py
index 9ae52ce3..a07be3b1 100644
--- a/fastkml/atom.py
+++ b/fastkml/atom.py
@@ -38,6 +38,7 @@
from fastkml.base import _XMLObject
from fastkml.config import ATOMNS as NS
+from fastkml.enums import Verbosity
from fastkml.helpers import o_from_attr
from fastkml.helpers import o_from_subelement_text
from fastkml.helpers import o_int_from_attr
@@ -167,8 +168,12 @@ def __init__(
def from_element(self, element: Element) -> None:
super().from_element(element)
- def etree_element(self) -> Element:
- return super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ return super().etree_element(precision=precision, verbosity=verbosity)
class _Person(_XMLObject):
@@ -227,8 +232,12 @@ def __init__(
self.uri = uri
self.email = email
- def etree_element(self) -> Element:
- return super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ return super().etree_element(precision=precision, verbosity=verbosity)
def from_element(self, element: Element) -> None:
super().from_element(element)
diff --git a/fastkml/base.py b/fastkml/base.py
index 7b2defba..048fbf91 100644
--- a/fastkml/base.py
+++ b/fastkml/base.py
@@ -16,11 +16,14 @@
"""Abstract base classes"""
import logging
+from typing import Any
+from typing import Dict
from typing import Optional
from typing import Tuple
from typing import cast
from fastkml import config
+from fastkml.enums import Verbosity
from fastkml.helpers import o_from_attr
from fastkml.helpers import o_to_attr
from fastkml.types import Element
@@ -32,14 +35,36 @@
class _XMLObject:
"""XML Baseclass."""
+ _namespaces: Tuple[str, ...] = ("",)
+ _node_name: str = ""
__name__ = ""
kml_object_mapping: Tuple[KmlObjectMap, ...] = ()
def __init__(self, ns: Optional[str] = None) -> None:
"""Initialize the XML Object."""
- self.ns: str = config.KMLNS if ns is None else ns
+ self.ns: str = self._namespaces[0] if ns is None else ns
- def etree_element(self) -> Element:
+ def __eq__(self, other: object) -> bool:
+ """Compare two XML Objects."""
+ if not isinstance(other, self.__class__):
+ return False
+ return (
+ other.ns == self.ns or other.ns in self._namespaces
+ if self.ns == ""
+ else True
+ )
+
+ def __repr__(self) -> str:
+ return f"{self.__class__.__name__}(ns={self.ns})"
+
+ def __str__(self) -> str:
+ return self.to_string()
+
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
"""Return the KML Object as an Element."""
if self.__name__:
element: Element = config.etree.Element( # type: ignore[attr-defined]
@@ -54,25 +79,44 @@ def etree_element(self) -> Element:
return element
def from_element(self, element: Element) -> None:
- """Load the KML Object from an Element."""
+ """
+ Load the KML Object from an Element.
+
+ This implementation is deprecated and will be replaced by class_from_element
+ making it a classmethod.
+ """
if f"{self.ns}{self.__name__}" != element.tag:
raise TypeError("Call of abstract base class, subclasses implement this!")
for mapping in self.kml_object_mapping:
mapping["from_kml"](self, element, **mapping)
def from_string(self, xml_string: str) -> None:
- """Load the KML Object from serialized xml."""
+ """
+ Load the KML Object from serialized xml.
+
+ This implementation is deprecated and will be replaced by class_from_string
+ making it a classmethod.
+ """
self.from_element(
cast(Element, config.etree.XML(xml_string)) # type: ignore[attr-defined]
)
- def to_string(self, prettyprint: bool = True) -> str:
+ def to_string(
+ self,
+ *,
+ prettyprint: bool = True,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> str:
"""Return the KML Object as serialized xml."""
try:
return cast(
str,
config.etree.tostring( # type: ignore[attr-defined]
- self.etree_element(),
+ self.etree_element(
+ precision=precision,
+ verbosity=verbosity,
+ ),
encoding="UTF-8",
pretty_print=prettyprint,
).decode("UTF-8"),
@@ -85,6 +129,63 @@ def to_string(self, prettyprint: bool = True) -> str:
).decode("UTF-8"),
)
+ @classmethod
+ def _get_ns(cls, ns: Optional[str]) -> str:
+ return cls._namespaces[0] if ns is None else ns
+
+ @classmethod
+ def _get_kwargs(
+ cls,
+ *,
+ ns: str,
+ element: Element,
+ strict: bool,
+ ) -> Dict[str, Any]:
+ """Returns a dictionary of kwargs for the class constructor."""
+ kwargs: Dict[str, Any] = {}
+ return kwargs
+
+ @classmethod
+ def class_from_element(
+ cls,
+ *,
+ ns: str,
+ element: Element,
+ strict: bool,
+ ) -> "_XMLObject":
+ """Creates an XML object from an etree element."""
+ kwargs = cls._get_kwargs(ns=ns, element=element, strict=strict)
+ return cls(
+ ns=ns,
+ **kwargs,
+ )
+
+ @classmethod
+ def class_from_string(
+ cls,
+ string: str,
+ *,
+ ns: Optional[str] = None,
+ strict: bool = True,
+ ) -> "_XMLObject":
+ """Creates a geometry object from a string.
+
+ Args:
+ string: String representation of the geometry object
+
+ Returns:
+ Geometry object
+ """
+ ns = cls._get_ns(ns)
+ return cls.class_from_element(
+ ns=ns,
+ strict=strict,
+ element=cast(
+ Element,
+ config.etree.fromstring(string), # type: ignore[attr-defined]
+ ),
+ )
+
class _BaseObject(_XMLObject):
"""
@@ -98,6 +199,9 @@ class _BaseObject(_XMLObject):
mechanism is to be used.
"""
+ _namespace = config.KMLNS
+ _namespaces: Tuple[str, ...] = (config.KMLNS,)
+
id = None
target_id = None
kml_object_mapping: Tuple[KmlObjectMap, ...] = (
@@ -130,10 +234,50 @@ def __init__(
self.id = id
self.target_id = target_id
- def etree_element(self) -> Element:
+ def __eq__(self, other: object) -> bool:
+ assert isinstance(other, self.__class__)
+ return (
+ super().__eq__(other)
+ and self.id == other.id
+ and self.target_id == other.target_id
+ )
+
+ def __repr__(self) -> str:
+ return (
+ f"{self.__class__.__name__}(ns={self.ns!r}, "
+ f"(id={self.id!r}, target_id={self.target_id!r})"
+ )
+
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
"""Return the KML Object as an Element."""
- return super().etree_element()
+ return super().etree_element(precision=precision, verbosity=verbosity)
def from_element(self, element: Element) -> None:
"""Load the KML Object from an Element."""
super().from_element(element)
+
+ @classmethod
+ def _get_id(cls, element: Element, strict: bool) -> str:
+ return element.get("id") or ""
+
+ @classmethod
+ def _get_target_id(cls, element: Element, strict: bool) -> str:
+ return element.get("targetId") or ""
+
+ @classmethod
+ def _get_kwargs(
+ cls,
+ *,
+ ns: str,
+ element: Element,
+ strict: bool,
+ ) -> Dict[str, Any]:
+ """Get the keyword arguments to build the object from an element."""
+ return {
+ "id": cls._get_id(element=element, strict=strict),
+ "target_id": cls._get_target_id(element=element, strict=strict),
+ }
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/data.py b/fastkml/data.py
index 18ace775..72a5bb7a 100644
--- a/fastkml/data.py
+++ b/fastkml/data.py
@@ -14,6 +14,7 @@
# along with this library; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
"""Add Custom Data"""
+import logging
from typing import Dict
from typing import List
from typing import Optional
@@ -26,8 +27,11 @@
import fastkml.config as config
from fastkml.base import _BaseObject
from fastkml.base import _XMLObject
+from fastkml.enums import Verbosity
from fastkml.types import Element
+logger = logging.getLogger(__name__)
+
class SimpleField(TypedDict):
name: str
@@ -55,11 +59,6 @@ class Schema(_BaseObject):
__name__ = "Schema"
- # The declaration of the custom fields, each of which must specify both the
- # type and the name of this field. If either the type or the name is
- # omitted, the field is ignored.
- name = None
-
def __init__(
self,
ns: Optional[str] = None,
@@ -150,11 +149,13 @@ def append(self, type: str, name: str, display_name: Optional[str] = None) -> No
"bool",
]
if type not in allowed_types:
- raise TypeError(
- f"{name} has the type {type} which is invalid. "
+ logger.warning(
+ "%s has the type %s which is invalid. "
"The type must be one of "
"'string', 'int', 'uint', 'short', "
- "'ushort', 'float', 'double', 'bool'"
+ "'ushort', 'float', 'double', 'bool'",
+ name,
+ type,
)
self._simple_fields.append(
{"type": type, "name": name, "displayName": display_name or ""}
@@ -171,8 +172,12 @@ def from_element(self, element: Element) -> None:
sfdisplay_name = display_name.text if display_name is not None else None
self.append(sftype, sfname, sfdisplay_name)
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self.name:
element.set("name", self.name)
for simple_field in self.simple_fields:
@@ -207,8 +212,20 @@ def __init__(
self.value = value
self.display_name = display_name
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def __repr__(self) -> str:
+ return (
+ f"{self.__class__.__name__}("
+ f"ns='{self.ns}',"
+ f"name='{self.name}', value='{self.value}'"
+ f"display_name='{self.display_name}')"
+ )
+
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
element.set("name", self.name or "")
value = config.etree.SubElement( # type: ignore[attr-defined]
element, f"{self.ns}value"
@@ -250,8 +267,12 @@ def __init__(
super().__init__(ns)
self.elements = elements or []
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
for subelement in self.elements:
element.append(subelement.etree_element())
return element
@@ -352,8 +373,12 @@ def append_data(self, name: str, value: Union[int, str]) -> None:
else:
raise TypeError("name must be a nonempty string")
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
element.set("schemaUrl", self.schema_url)
for data in self.data:
sd = config.etree.SubElement( # type: ignore[attr-defined]
diff --git a/fastkml/enums.py b/fastkml/enums.py
index b875796c..8c1ab589 100644
--- a/fastkml/enums.py
+++ b/fastkml/enums.py
@@ -17,6 +17,15 @@
from enum import unique
+@unique
+class Verbosity(Enum):
+ """Enum to represent the different verbosity levels."""
+
+ quiet = 0
+ normal = 1
+ verbose = 2
+
+
@unique
class DateTimeResolution(Enum):
"""Enum to represent the different date time resolutions."""
@@ -25,3 +34,51 @@ class DateTimeResolution(Enum):
date = "date"
year_month = "gYearMonth"
year = "gYear"
+
+
+@unique
+class AltitudeMode(Enum):
+ """
+ Enum to represent the different altitude modes.
+
+ Specifies how altitude components in the element are interpreted.
+ Possible values are
+ - clampToGround - (default) Indicates to ignore an altitude specification
+ (for example, in the tag).
+ - 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.
+ - relativeToSeaFloor - Interprets the altitude as a value in meters above the
+ sea floor.
+ If the point is above land rather than sea, the altitude will be interpreted
+ as being above the ground.
+ - clampToSeaFloor - The altitude specification is ignored, and the point will be
+ positioned on the sea floor.
+ If the point is on land rather than at sea, the point will be positioned on
+ the ground.
+
+ The Values relativeToSeaFloor and clampToSeaFloor are not part of the KML definition
+ but of the a KML extension in the Google extension namespace,
+ allowing altitudes relative to the sea floor.
+ """
+
+ clamp_to_ground = "clampToGround"
+ relative_to_ground = "relativeToGround"
+ absolute = "absolute"
+ clamp_to_sea_floor = "clampToSeaFloor"
+ relative_to_sea_floor = "relativeToSeaFloor"
diff --git a/tests/geometry_test.py b/fastkml/exceptions.py
similarity index 68%
rename from tests/geometry_test.py
rename to fastkml/exceptions.py
index 25499538..600aaee3 100644
--- a/tests/geometry_test.py
+++ b/fastkml/exceptions.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2021 Christian Ledermann
+# Copyright (C) 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
@@ -13,15 +13,16 @@
# 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
+"""Exceptions for the fastkml package."""
-"""Test the geometry classes."""
-from tests.base import Lxml
-from tests.base import StdLibrary
+class FastKMLError(Exception):
+ """Base class for all fastkml exceptions."""
-class TestStdLibrary(StdLibrary):
- """Test with the standard library."""
+class KMLParseError(FastKMLError):
+ """Raised when there is an error parsing KML."""
-class TestLxml(Lxml, TestStdLibrary):
- """Test with lxml."""
+
+class KMLWriteError(FastKMLError):
+ """Raised when there is an error writing KML."""
diff --git a/fastkml/geometry.py b/fastkml/geometry.py
index 62b0c5ab..7a8aa5db 100644
--- a/fastkml/geometry.py
+++ b/fastkml/geometry.py
@@ -14,151 +14,114 @@
# along with this library; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+import contextlib
import logging
import re
+from functools import partial
from typing import Any
+from typing import Dict
from typing import List
from typing import Optional
from typing import Sequence
from typing import Union
from typing import cast
-from pygeoif.factories import shape
-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
+import pygeoif.geometry as geo
from pygeoif.types import PointType
from fastkml import config
from fastkml.base import _BaseObject
+from fastkml.enums import AltitudeMode
+from fastkml.enums import Verbosity
+from fastkml.exceptions import KMLParseError
+from fastkml.exceptions import KMLWriteError
from fastkml.types import Element
logger = logging.getLogger(__name__)
-GeometryType = Union[Polygon, LineString, LinearRing, Point]
-MultiGeometryType = Union[MultiPoint, MultiLineString, MultiPolygon, GeometryCollection]
+GeometryType = Union[geo.Polygon, geo.LineString, geo.LinearRing, geo.Point]
+MultiGeometryType = Union[
+ geo.MultiPoint, geo.MultiLineString, geo.MultiPolygon, geo.GeometryCollection
+]
AnyGeometryType = Union[GeometryType, MultiGeometryType]
-class Geometry(_BaseObject):
- """ """
+class _Geometry(_BaseObject):
+ """Baseclass with common methods for all geometry objects.
- geometry = None
- extrude = False
- tessellate = False
- altitude_mode = None
+ Attributes: extrude: boolean --> Specifies whether to connect the feature to
+ the ground with a line.
+ tessellate: boolean --> Specifies whether to allow the LineString
+ to follow the terrain.
+ altitudeMode: --> Specifies how altitude components in the
+ element are interpreted.
+
+ """
def __init__(
self,
+ *,
ns: Optional[str] = None,
id: Optional[str] = None,
- geometry: Optional[Any] = None,
- extrude: bool = False,
- tessellate: bool = False,
- altitude_mode: Optional[str] = None,
+ target_id: Optional[str] = None,
+ extrude: Optional[bool] = False,
+ tessellate: Optional[bool] = False,
+ altitude_mode: Optional[AltitudeMode] = None,
+ geometry: Optional[AnyGeometryType] = 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
+
+ Args:
+ ns: Namespace of the object
+ id: Id of the object
+ target_id: Target id of the object
+ extrude: Specifies whether to connect the feature to the ground with a line.
+ tessellate: Specifies whether to allow the LineString to follow the terrain.
+ altitude_mode: Specifies how altitude components in the
+ element are interpreted.
"""
- super().__init__(ns, id)
- self.extrude = extrude
- self.tessellate = tessellate
- self.altitude_mode = altitude_mode
- if geometry:
- if isinstance(
- geometry,
- (
- Point,
- LineString,
- Polygon,
- MultiPoint,
- MultiLineString,
- MultiPolygon,
- LinearRing,
- GeometryCollection,
- ),
- ):
- self.geometry = geometry
- else:
- self.geometry = shape(geometry)
+ super().__init__(ns=ns, id=id, target_id=target_id)
+ self._extrude = extrude
+ self._tessellate = tessellate
+ self._altitude_mode = altitude_mode
+ self.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"geometry={self.geometry!r}"
+ f")"
+ )
- # write kml
+ @property
+ def extrude(self) -> Optional[bool]:
+ return self._extrude
- def _set_altitude_mode(self, element: Element) -> None:
- if self.altitude_mode:
- # XXX add 'relativeToSeaFloor', 'clampToSeaFloor',
- 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
+ @extrude.setter
+ def extrude(self, extrude: bool) -> None:
+ self._extrude = extrude
- def _set_extrude(self, element: Element) -> None:
- if self.extrude and self.altitude_mode in [
- "relativeToGround",
- # 'relativeToSeaFloor',
- "absolute",
- ]:
- et_element = cast(
- Element,
- config.etree.SubElement( # type: ignore[attr-defined]
- element, f"{self.ns}extrude"
- ),
- )
- et_element.text = "1"
+ @property
+ def tessellate(self) -> Optional[bool]:
+ return self._tessellate
+
+ @tessellate.setter
+ def tessellate(self, tessellate: bool) -> None:
+ self._tessellate = tessellate
+
+ @property
+ def altitude_mode(self) -> Optional[AltitudeMode]:
+ return self._altitude_mode
+
+ @altitude_mode.setter
+ def altitude_mode(self, altitude_mode: Optional[AltitudeMode]) -> None:
+ self._altitude_mode = altitude_mode
def _etree_coordinates(
self,
@@ -169,305 +132,506 @@ def _etree_coordinates(
config.etree.Element(f"{self.ns}coordinates"), # type: ignore[attr-defined]
)
if len(coordinates[0]) == 2:
- if config.FORCE3D: # and not clampToGround:
- tuples = (f"{c[0]:f},{c[1]:f},0.000000" for c in coordinates)
- else:
- tuples = (f"{c[0]:f},{c[1]:f}" for c in coordinates)
+ 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")
+ raise KMLWriteError(f"Invalid dimensions in coordinates '{coordinates}'")
element.text = " ".join(tuples)
return element
- def _etree_point(self, point: Point) -> Element:
- element = self._extrude_and_altitude_mode("Point")
- return self._extracted_from__etree_linearring_5(point, element)
-
- def _etree_linestring(self, linestring: LineString) -> Element:
- element = self._extrude_and_altitude_mode("LineString")
- if self.tessellate and self.altitude_mode in [
- "clampToGround",
- "clampToSeaFloor",
- ]:
- ts_element = config.etree.SubElement( # type: ignore[attr-defined]
- element, f"{self.ns}tessellate"
+ def _set_altitude_mode(self, element: Element) -> None:
+ if self.altitude_mode:
+ am_element = config.etree.SubElement( # type: ignore[attr-defined]
+ element, f"{self.ns}altitudeMode"
)
- ts_element.text = "1"
- return self._extracted_from__etree_linearring_5(linestring, element)
+ am_element.text = self.altitude_mode.value
- def _etree_linearring(self, linearring: LinearRing) -> Element:
- element = self._extrude_and_altitude_mode("LinearRing")
- return self._extracted_from__etree_linearring_5(linearring, element)
-
- def _extracted_from__etree_linearring_5(
- self, arg0: Union[LineString, LinearRing, Point], element: Element
- ) -> Element:
- coords = list(arg0.coords)
- element.append(self._etree_coordinates(coords))
- return element
+ def _set_extrude(self, element: Element) -> None:
+ if self.extrude is not None:
+ et_element = cast(
+ Element,
+ config.etree.SubElement( # type: ignore[attr-defined]
+ element, f"{self.ns}extrude"
+ ),
+ )
+ et_element.text = str(int(self.extrude))
- def _etree_polygon(self, polygon: 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(
+ def _set_tessellate(self, element: Element) -> None:
+ if self.tessellate is not None:
+ t_element = cast(
Element,
config.etree.SubElement( # type: ignore[attr-defined]
- element,
- f"{self.ns}innerBoundaryIs",
+ element, f"{self.ns}tessellate"
),
)
- inner_boundary.append(self._etree_linearring(ib))
+ t_element.text = str(int(self.tessellate))
+
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ self.__name__ = self.__class__.__name__
+ element = super().etree_element(precision=precision, verbosity=verbosity)
+ self._set_extrude(element)
+ self._set_altitude_mode(element)
+ self._set_tessellate(element)
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}"
+ @classmethod
+ def _get_coordinates(
+ cls, *, ns: str, element: Element, strict: bool
+ ) -> List[PointType]:
+ """
+ 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 []
+ return [
+ cast(PointType, tuple(float(c) for c in latlon.split(",")))
+ for latlon in latlons
+ ]
+ return []
+
+ @classmethod
+ def _get_extrude(
+ cls,
+ *,
+ ns: str,
+ element: Element,
+ strict: bool,
+ ) -> Optional[bool]:
+ extrude = element.find(f"{ns}extrude")
+ if extrude is None:
+ return None
+ with contextlib.suppress(ValueError, AttributeError):
+ return bool(int(extrude.text.strip()))
+ return None
+
+ @classmethod
+ def _get_tessellate(
+ cls,
+ *,
+ ns: str,
+ element: Element,
+ strict: bool,
+ ) -> Optional[bool]:
+ tessellate = element.find(f"{ns}tessellate")
+ if tessellate is None:
+ return None
+ with contextlib.suppress(ValueError):
+ return bool(int(tessellate.text.strip()))
+ return None
+
+ @classmethod
+ def _get_altitude_mode(
+ cls,
+ *,
+ ns: str,
+ element: Element,
+ strict: bool,
+ ) -> Optional[AltitudeMode]:
+ altitude_mode = element.find(f"{ns}altitudeMode")
+ if altitude_mode is None:
+ return None
+ with contextlib.suppress(ValueError):
+ return AltitudeMode(altitude_mode.text.strip())
+ return None
+
+ @classmethod
+ def _get_geometry_kwargs(
+ cls,
+ *,
+ ns: str,
+ element: Element,
+ strict: bool,
+ ) -> Dict[str, Any]:
+ return {
+ "extrude": cls._get_extrude(ns=ns, element=element, strict=strict),
+ "tessellate": cls._get_tessellate(ns=ns, element=element, strict=strict),
+ "altitude_mode": cls._get_altitude_mode(
+ ns=ns, element=element, strict=strict
),
+ }
+
+ @classmethod
+ def _get_geometry(
+ cls,
+ *,
+ ns: str,
+ element: Element,
+ strict: bool,
+ ) -> Optional[AnyGeometryType]:
+ return 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.update(cls._get_geometry_kwargs(ns=ns, element=element, strict=strict))
+ kwargs.update(
+ {"geometry": cls._get_geometry(ns=ns, element=element, strict=strict)}
)
- self._set_extrude(result)
- self._set_altitude_mode(result)
- return result
+ return kwargs
- def _etree_multipoint(self, points: MultiPoint) -> Element:
- element = cast(
- Element,
- config.etree.Element( # type: ignore[attr-defined]
- f"{self.ns}MultiGeometry"
- ),
+
+class Point(_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: geo.Point,
+ ) -> None:
+ super().__init__(
+ ns=ns,
+ id=id,
+ target_id=target_id,
+ extrude=extrude,
+ tessellate=tessellate,
+ altitude_mode=altitude_mode,
+ geometry=geometry,
)
- for point in points.geoms:
- element.append(self._etree_point(point))
+
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ self.__name__ = self.__class__.__name__
+ element = super().etree_element(precision=precision, verbosity=verbosity)
+ assert isinstance(self.geometry, geo.Point)
+ coords = self.geometry.coords
+ element.append(self._etree_coordinates(coords))
return element
- def _etree_multilinestring(self, linestrings: MultiLineString) -> Element:
- element = cast(
- Element,
- config.etree.Element( # type: ignore[attr-defined]
- f"{self.ns}MultiGeometry"
- ),
+ @classmethod
+ def _get_geometry(
+ cls,
+ *,
+ ns: str,
+ element: Element,
+ strict: bool,
+ ) -> geo.Point:
+ coords = cls._get_coordinates(ns=ns, element=element, strict=strict)
+ try:
+ return geo.Point.from_coordinates(coords)
+ except (IndexError, TypeError) as e:
+ error = config.etree.tostring( # type: ignore[attr-defined]
+ element,
+ encoding="UTF-8",
+ ).decode("UTF-8")
+ raise KMLParseError(f"Invalid coordinates in {error}") from e
+
+
+class LineString(_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: geo.LineString,
+ ) -> None:
+ super().__init__(
+ ns=ns,
+ id=id,
+ target_id=target_id,
+ extrude=extrude,
+ tessellate=tessellate,
+ altitude_mode=altitude_mode,
+ geometry=geometry,
)
- for linestring in linestrings.geoms:
- element.append(self._etree_linestring(linestring))
+
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ self.__name__ = self.__class__.__name__
+ element = super().etree_element(precision=precision, verbosity=verbosity)
+ assert isinstance(self.geometry, geo.LineString)
+ coords = self.geometry.coords
+ element.append(self._etree_coordinates(coords))
return element
- def _etree_multipolygon(self, polygons: MultiPolygon) -> Element:
- element = cast(
- Element,
- config.etree.Element( # type: ignore[attr-defined]
- f"{self.ns}MultiGeometry"
- ),
+ @classmethod
+ def _get_geometry(
+ cls,
+ *,
+ ns: str,
+ element: Element,
+ strict: bool,
+ ) -> geo.LineString:
+ coords = cls._get_coordinates(ns=ns, element=element, strict=strict)
+ try:
+ return geo.LineString.from_coordinates(coords)
+ except (IndexError, TypeError) as e:
+ error = config.etree.tostring( # type: ignore[attr-defined]
+ element,
+ encoding="UTF-8",
+ ).decode("UTF-8")
+ raise KMLParseError(f"Invalid coordinates in {error}") from e
+
+
+class LinearRing(LineString):
+ 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: geo.LinearRing,
+ ) -> None:
+ super().__init__(
+ ns=ns,
+ id=id,
+ target_id=target_id,
+ extrude=extrude,
+ tessellate=tessellate,
+ altitude_mode=altitude_mode,
+ geometry=geometry,
)
- for polygon in polygons.geoms:
- element.append(self._etree_polygon(polygon))
- return element
- def _etree_collection(self, features: GeometryCollection) -> Element:
- element = cast(
+ @classmethod
+ def _get_geometry(
+ cls,
+ *,
+ ns: str,
+ element: Element,
+ strict: bool,
+ ) -> 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) as e:
+ error = config.etree.tostring( # type: ignore[attr-defined]
+ element,
+ encoding="UTF-8",
+ ).decode("UTF-8")
+ raise KMLParseError(f"Invalid coordinates in {error}") from e
+
+
+class Polygon(_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: geo.Polygon,
+ ) -> None:
+ super().__init__(
+ ns=ns,
+ id=id,
+ target_id=target_id,
+ extrude=extrude,
+ tessellate=tessellate,
+ altitude_mode=altitude_mode,
+ geometry=geometry,
+ )
+
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ self.__name__ = self.__class__.__name__
+ element = super().etree_element(precision=precision, verbosity=verbosity)
+ assert isinstance(self.geometry, geo.Polygon)
+ linear_ring = partial(LinearRing, ns=self.ns, extrude=None, tessellate=None)
+ outer_boundary = cast(
Element,
- config.etree.Element( # type: ignore[attr-defined]
- f"{self.ns}MultiGeometry"
+ config.etree.SubElement( # type: ignore[attr-defined]
+ element,
+ f"{self.ns}outerBoundaryIs",
),
)
- for feature in features.geoms:
- if feature.geom_type == "Point":
- element.append(self._etree_point(cast(Point, feature)))
- elif feature.geom_type == "LinearRing":
- element.append(self._etree_linearring(cast(LinearRing, feature)))
- elif feature.geom_type == "LineString":
- element.append(self._etree_linestring(cast(LineString, feature)))
- elif feature.geom_type == "Polygon":
- element.append(self._etree_polygon(cast(Polygon, feature)))
- else:
- raise ValueError("Illegal geometry type.")
+ 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
- def etree_element(self) -> Element:
- if isinstance(self.geometry, Point):
- return self._etree_point(self.geometry)
- elif isinstance(self.geometry, LinearRing):
- return self._etree_linearring(self.geometry)
- elif isinstance(self.geometry, LineString):
- return self._etree_linestring(self.geometry)
- elif isinstance(self.geometry, Polygon):
- return self._etree_polygon(self.geometry)
- elif isinstance(self.geometry, MultiPoint):
- return self._etree_multipoint(self.geometry)
- elif isinstance(self.geometry, MultiLineString):
- return self._etree_multilinestring(self.geometry)
- elif isinstance(self.geometry, MultiPolygon):
- return self._etree_multipolygon(self.geometry)
- elif isinstance(self.geometry, GeometryCollection):
- return self._etree_collection(self.geometry)
- else:
- raise ValueError("Illegal geometry type.")
+ @classmethod
+ def _get_geometry(cls, *, ns: str, element: Element, strict: bool) -> 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")
+ raise KMLParseError(f"Missing outerBoundaryIs in {error}")
+ 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")
+ raise KMLParseError(f"Missing LinearRing in {error}")
+ 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")
+ raise KMLParseError(f"Missing LinearRing in {error}")
+ interiors.append(
+ LinearRing._get_geometry(ns=ns, element=inner_ring, strict=strict)
+ )
+ return geo.Polygon.from_linear_rings(exterior, *interiors)
- # 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 # type: ignore[unreachable]
- 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 # type: ignore[unreachable]
- altitude_mode = element.find(f"{self.ns}altitudeMode")
- if altitude_mode is not None:
- am = altitude_mode.text.strip()
- if am in [
- "clampToGround",
- # 'relativeToSeaFloor', 'clampToSeaFloor',
- "relativeToGround",
- "absolute",
- ]:
- self.altitude_mode = am
- else:
- self.altitude_mode = None
- else:
- self.altitude_mode = None # type: ignore[unreachable]
+def create_multigeometry(
+ geometries: Sequence[AnyGeometryType],
+) -> Optional[MultiGeometryType]:
+ """Create a MultiGeometry from a sequence of geometries.
- def _get_coordinates(self, element: Element) -> 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
- ]
+ Args:
+ geometries: Sequence of geometries.
- def _get_linear_ring(self, element: Element) -> Optional[LinearRing]:
- # LinearRing in polygon
- lr = element.find(f"{self.ns}LinearRing")
- if lr is not None:
- coords = self._get_coordinates(lr)
- return LinearRing(coords)
- return None # type: ignore[unreachable]
-
- 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 Point.from_coordinates(coords)
- if element.tag == f"{self.ns}LineString":
- coords = self._get_coordinates(element)
- self._get_geometry_spec(element)
- return 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 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 LinearRing(coords)
- return None
+ Returns:
+ MultiGeometry
- def _get_multigeometry(self, element: Element) -> Optional[MultiGeometryType]:
- # MultiGeometry
- geoms: List[Union[AnyGeometryType, None]] = []
- if element.tag == f"{self.ns}MultiGeometry":
- points = element.findall(f"{self.ns}Point")
- for point in points:
- self._get_geometry_spec(point)
- geoms.append(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(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[LinearRing] = [ib for ib in inner_bs if ib]
- geoms.append(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(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 GeometryCollection(
- clean_geoms, # type: ignore[arg-type]
- )
- if "Point" in geom_types:
- return MultiPoint.from_points(
- *clean_geoms, # type: ignore[arg-type]
- )
- elif "LineString" in geom_types:
- return MultiLineString.from_linestrings(
- *clean_geoms, # type: ignore[arg-type]
- )
- elif "Polygon" in geom_types:
- return MultiPolygon.from_polygons(
- *clean_geoms, # type: ignore[arg-type]
- )
- elif "LinearRing" in geom_types:
- return GeometryCollection(
- clean_geoms, # type: ignore[arg-type]
- )
+ """
+ geom_types = {geom.geom_type for geom in geometries}
+ if not geom_types:
return None
+ if len(geom_types) == 1:
+ geom_type = geom_types.pop()
+ map_to_geometries = {
+ geo.Point.__name__: geo.MultiPoint.from_points,
+ geo.LineString.__name__: geo.MultiLineString.from_linestrings,
+ geo.Polygon.__name__: geo.MultiPolygon.from_polygons,
+ }
+ for geometry_name, constructor in map_to_geometries.items():
+ if geom_type == geometry_name:
+ return constructor( # type: ignore[operator, no-any-return]
+ *geometries,
+ )
+
+ return geo.GeometryCollection(geometries)
- def from_element(self, element: Element) -> None:
- 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 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,
+ id: Optional[str] = None,
+ target_id: Optional[str] = None,
+ extrude: Optional[bool] = False,
+ tessellate: Optional[bool] = False,
+ altitude_mode: Optional[AltitudeMode] = None,
+ geometry: MultiGeometryType,
+ ) -> None:
+ super().__init__(
+ ns=ns,
+ id=id,
+ target_id=target_id,
+ extrude=extrude,
+ tessellate=tessellate,
+ altitude_mode=altitude_mode,
+ geometry=geometry,
+ )
+
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ self.__name__ = self.__class__.__name__
+ 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,
+ extrude=None,
+ tessellate=None,
+ altitude_mode=None,
+ geometry=geometry, # type: ignore[arg-type]
+ ).etree_element(precision=precision, verbosity=verbosity)
+ )
+ return element
-__all__ = ["Geometry"]
+ @classmethod
+ def _get_geometry(
+ cls, *, ns: str, element: Element, strict: bool
+ ) -> Optional[MultiGeometryType]:
+ geometries = []
+ allowed_geometries = (cls,) + tuple(cls.map_to_kml.values())
+ for g in allowed_geometries:
+ for e in element.findall(f"{ns}{g.__name__}"):
+ geometry = g._get_geometry(ns=ns, element=e, strict=strict)
+ if geometry is not None:
+ geometries.append(geometry)
+ return create_multigeometry(geometries)
diff --git a/fastkml/gx.py b/fastkml/gx.py
index 86e42c6c..892850e9 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,79 +75,346 @@
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
+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 Union
+from typing import Sequence
from typing import cast
-from pygeoif.geometry import GeometryCollection
-from pygeoif.geometry import LineString
-from pygeoif.geometry import MultiLineString
-from pygeoif.types import PointType
+import dateutil.parser
+import pygeoif.geometry as geo
-from fastkml.config import GXNS as NS
-from fastkml.geometry import Geometry
+import fastkml.config as config
+from fastkml.enums import AltitudeMode
+from fastkml.enums import Verbosity
+from fastkml.geometry import _Geometry
from fastkml.types import Element
logger = logging.getLogger(__name__)
-class GxGeometry(Geometry):
+@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 objects position and heading at a specific time."""
+
+ 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.
+
+ 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: None = None,
- id: None = None,
+ *,
+ 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.LineString] = None,
+ track_items: Optional[Sequence[TrackItem]] = None,
) -> None:
- """
- gxgeometry: a read-only subclass of geometry supporting gx: features,
- like gx:Track
- """
- super().__init__(ns, id)
- self.ns = NS if ns is None else ns
-
- def _get_geometry(self, element: Element) -> Optional[LineString]:
- # Track
- if element.tag == (f"{self.ns}Track"):
- coords = self._get_coordinates(element)
- self._get_geometry_spec(element)
- return LineString(
- coords,
- )
- return None
+ if geometry and track_items:
+ raise ValueError("Cannot specify both geometry and track_items")
+ if geometry:
+ track_items = linestring_to_track_items(geometry)
+ elif track_items:
+ geometry = track_items_to_geometry(track_items)
+ self.track_items = track_items
+ super().__init__(
+ ns=ns,
+ id=id,
+ target_id=target_id,
+ extrude=extrude,
+ tessellate=tessellate,
+ altitude_mode=altitude_mode,
+ geometry=geometry,
+ )
- def _get_multigeometry(
+ 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,
- ) -> Union[MultiLineString, 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(
- LineString(
- self._get_coordinates(track),
- )
+ 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)
+ return [
+ TrackItem(when=when, coord=coord, angle=angle)
+ for when, coord, angle in zip_longest(time_stamps, coords, angles)
+ ]
- geom_types = {geom.geom_type for geom in geoms}
- if len(geom_types) > 1:
- return GeometryCollection(geoms)
- if "LineString" in geom_types:
- return MultiLineString.from_linestrings(*geoms)
- return 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["track_items"] = cls.track_items_kwargs_from_element(
+ ns=ns, element=element, strict=strict
+ )
+ return kwargs
+
+
+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 _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]
+ 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 or []:
+ element.append(
+ track.etree_element(
+ precision=precision, verbosity=verbosity, name_spaces=name_spaces
+ )
+ )
+ return element
-__all__ = ["GxGeometry"]
+ @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/helpers.py b/fastkml/helpers.py
index 24f6c2a4..781ea623 100644
--- a/fastkml/helpers.py
+++ b/fastkml/helpers.py
@@ -109,7 +109,7 @@ def o_from_subelement_text(
)
else:
setattr(obj, obj_attr, elem.text)
- elif required: # type: ignore[unreachable]
+ elif required:
logger.warning(
"Required attribute '%s' for '%s' missing.",
kml_attr,
diff --git a/fastkml/kml.py b/fastkml/kml.py
index 2b18d466..848f1614 100644
--- a/fastkml/kml.py
+++ b/fastkml/kml.py
@@ -37,7 +37,13 @@
from fastkml.base import _BaseObject
from fastkml.data import ExtendedData
from fastkml.data import Schema
-from fastkml.geometry import Geometry
+from fastkml.enums import Verbosity
+from fastkml.geometry import AnyGeometryType
+from fastkml.geometry import LinearRing
+from fastkml.geometry import LineString
+from fastkml.geometry import MultiGeometry
+from fastkml.geometry import Point
+from fastkml.geometry import Polygon
from fastkml.mixins import TimeMixin
from fastkml.styles import Style
from fastkml.styles import StyleMap
@@ -51,6 +57,16 @@
logger = logging.getLogger(__name__)
+KmlGeometry = Union[
+ Point,
+ LineString,
+ LinearRing,
+ Polygon,
+ MultiGeometry,
+ gx.MultiTrack,
+ gx.Track,
+]
+
class _Feature(TimeMixin, _BaseObject):
"""
@@ -336,8 +352,12 @@ def phone_number(self, phone_number):
else:
raise ValueError
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self.name:
name = config.etree.SubElement(element, f"{self.ns}name")
name.text = self.name
@@ -448,10 +468,6 @@ def from_element(self, element: Element) -> None:
x = ExtendedData(self.ns)
x.from_element(extended_data)
self.extended_data = x
- # else:
- # logger.warn(
- # 'arbitrary or typed extended data is not yet supported'
- # )
address = element.find(f"{self.ns}address")
if address is not None:
self.address = address.text
@@ -689,8 +705,12 @@ def http_query(self, http_query):
else:
raise ValueError
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self._href:
href = config.etree.SubElement(element, f"{self.ns}href")
@@ -817,14 +837,20 @@ def features(self) -> Iterator[_Feature]:
"(Folder, Placemark, Document, Overlay)"
)
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
for feature in self.features():
element.append(feature.etree_element())
return element
def append(self, kmlobj: _Feature) -> None:
"""append a feature"""
+ if id(kmlobj) == id(self):
+ raise ValueError("Cannot append self")
if isinstance(kmlobj, (Folder, Placemark, Document, _Overlay)):
self._features.append(kmlobj)
else:
@@ -832,7 +858,6 @@ def append(self, kmlobj: _Feature) -> None:
"Features must be instances of "
"(Folder, Placemark, Document, Overlay)"
)
- assert kmlobj != self
class _Overlay(_Feature):
@@ -924,8 +949,12 @@ def icon(self, value):
else:
raise ValueError
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self._color:
color = config.etree.SubElement(element, f"{self.ns}color")
color.text = self._color
@@ -1208,8 +1237,12 @@ def image_pyramid(self, tile_size, max_width, max_height, grid_origin):
self.max_height = max_height
self.grid_origin = grid_origin
- def etree_element(self):
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ):
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self._rotation:
rotation = config.etree.SubElement(element, f"{self.ns}rotation")
rotation.text = self._rotation
@@ -1464,8 +1497,12 @@ def lat_lon_box(
else:
raise ValueError
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self._altitude:
altitude = config.etree.SubElement(element, f"{self.ns}altitude")
altitude.text = self._altitude
@@ -1562,8 +1599,12 @@ def from_element(self, element: Element) -> None:
s.from_element(schema)
self.append_schema(s)
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self._schemata is not None:
for schema in self._schemata:
element.append(schema.etree_element())
@@ -1614,67 +1655,104 @@ 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) -> None:
+ def from_element(self, element: Element, strict=False) -> None:
super().from_element(element)
point = element.find(f"{self.ns}Point")
if point is not None:
- geom = Geometry(ns=self.ns)
- geom.from_element(point)
- self._geometry = geom
+ self._geometry = Point.class_from_element(
+ ns=self.ns,
+ element=point,
+ strict=strict,
+ )
return
line = element.find(f"{self.ns}LineString")
if line is not None:
- geom = Geometry(ns=self.ns)
- geom.from_element(line)
- self._geometry = geom
+ self._geometry = LineString.class_from_element(
+ ns=self.ns,
+ element=line,
+ strict=strict,
+ )
return
polygon = element.find(f"{self.ns}Polygon")
if polygon is not None:
- geom = Geometry(ns=self.ns)
- geom.from_element(polygon)
- self._geometry = geom
+ self._geometry = Polygon.class_from_element(
+ ns=self.ns,
+ element=polygon,
+ strict=strict,
+ )
return
linearring = element.find(f"{self.ns}LinearRing")
if linearring is not None:
- geom = Geometry(ns=self.ns)
- geom.from_element(linearring)
- self._geometry = geom
+ self._geometry = LinearRing.class_from_element(
+ ns=self.ns,
+ element=linearring,
+ strict=strict,
+ )
return
multigeometry = element.find(f"{self.ns}MultiGeometry")
if multigeometry is not None:
- geom = Geometry(ns=self.ns)
- geom.from_element(multigeometry)
- self._geometry = geom
+ self._geometry = MultiGeometry.class_from_element(
+ ns=self.ns,
+ element=multigeometry,
+ strict=strict,
+ )
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))
# raise ValueError('No geometries found')
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self._geometry is not None:
element.append(self._geometry.etree_element())
else:
@@ -1737,7 +1815,11 @@ def from_string(self, xml_string: str) -> None:
feature.from_element(photo_overlay)
self.append(feature)
- def etree_element(self) -> Element:
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
# self.ns may be empty, which leads to unprefixed kml elements.
# However, in this case the xlmns should still be mentioned on the kml
# element, just without prefix.
@@ -1772,7 +1854,6 @@ def features(self) -> Iterator[Union[Folder, Document, Placemark]]:
"""iterate over features"""
for feature in self._features:
if isinstance(feature, (Document, Folder, Placemark, _Overlay)):
-
yield feature
else:
raise TypeError(
@@ -1782,7 +1863,8 @@ def features(self) -> Iterator[Union[Folder, Document, Placemark]]:
def append(self, kmlobj: Union[Folder, Document, Placemark]) -> None:
"""append a feature"""
-
+ if id(kmlobj) == id(self):
+ raise ValueError("Cannot append self")
if isinstance(kmlobj, (Document, Folder, Placemark, _Overlay)):
self._features.append(kmlobj)
else:
diff --git a/fastkml/mixins.py b/fastkml/mixins.py
index 2ddc0d77..60b56a12 100644
--- a/fastkml/mixins.py
+++ b/fastkml/mixins.py
@@ -25,7 +25,6 @@
class TimeMixin:
-
_timespan: Optional[TimeSpan] = None
_timestamp: Optional[TimeStamp] = None
diff --git a/fastkml/styles.py b/fastkml/styles.py
index 5df7a08c..48240c65 100644
--- a/fastkml/styles.py
+++ b/fastkml/styles.py
@@ -32,6 +32,7 @@
from fastkml import config
from fastkml.base import _BaseObject
+from fastkml.enums import Verbosity
from fastkml.types import Element
logger = logging.getLogger(__name__)
@@ -57,8 +58,12 @@ def __init__(
super().__init__(ns=ns, id=id, target_id=target_id)
self.url = url
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self.url:
element.text = self.url
else:
@@ -113,8 +118,12 @@ def __init__(
self.color = color
self.color_mode = color_mode
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self.color:
color = config.etree.SubElement( # type: ignore[attr-defined]
element,
@@ -130,7 +139,6 @@ def etree_element(self) -> Element:
return element
def from_element(self, element: Element) -> None:
-
super().from_element(element)
color_mode = element.find(f"{self.ns}colorMode")
if color_mode is not None:
@@ -181,8 +189,12 @@ def __init__(
self.icon_href = icon_href
self.hot_spot = hot_spot
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self.scale is not None:
scale = config.etree.SubElement( # type: ignore[attr-defined]
element,
@@ -264,8 +276,12 @@ def __init__(
)
self.width = width
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self.width is not None:
width = config.etree.SubElement( # type: ignore[attr-defined]
element,
@@ -311,8 +327,12 @@ def __init__(
self.fill = fill
self.outline = outline
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self.fill is not None:
fill = config.etree.SubElement( # type: ignore[attr-defined]
element,
@@ -368,8 +388,12 @@ def __init__(
)
self.scale = scale
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self.scale is not None:
scale = config.etree.SubElement( # type: ignore[attr-defined]
element,
@@ -458,7 +482,7 @@ def from_element(self, element: Element) -> None:
if bg_color is not None:
self.bg_color = bg_color.text
else:
- bg_color = element.find(f"{self.ns}color") # type: ignore[unreachable]
+ bg_color = element.find(f"{self.ns}color")
if bg_color is not None:
self.bg_color = bg_color.text
text_color = element.find(f"{self.ns}textColor")
@@ -471,8 +495,12 @@ def from_element(self, element: Element) -> None:
if display_mode is not None:
self.display_mode = display_mode.text
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self.bg_color is not None:
elem = config.etree.SubElement( # type: ignore[attr-defined]
element,
@@ -558,8 +586,12 @@ def from_element(self, element: Element) -> None:
for style in [BalloonStyle, IconStyle, LabelStyle, LineStyle, PolyStyle]:
self._get_style(element, style)
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
for style in self.styles():
element.append(style.etree_element())
return element
@@ -596,12 +628,14 @@ def from_element(self, element: Element) -> None:
key = pair.find(f"{self.ns}key")
style = pair.find(f"{self.ns}Style")
style_url = pair.find(f"{self.ns}styleUrl")
- if key.text == "highlight":
+ if key is None:
+ raise ValueError
+ elif key.text == "highlight":
if style is not None:
highlight = Style(self.ns)
highlight.from_element(style)
- elif style_url is not None: # type: ignore[unreachable]
- highlight = StyleUrl(self.ns)
+ elif style_url is not None:
+ highlight = StyleUrl(self.ns) # type: ignore[assignment]
highlight.from_element(style_url)
else:
raise ValueError
@@ -610,8 +644,8 @@ def from_element(self, element: Element) -> None:
if style is not None:
normal = Style(self.ns)
normal.from_element(style)
- elif style_url is not None: # type: ignore[unreachable]
- normal = StyleUrl(self.ns)
+ elif style_url is not None:
+ normal = StyleUrl(self.ns) # type: ignore[assignment]
normal.from_element(style_url)
else:
raise ValueError
@@ -619,8 +653,12 @@ def from_element(self, element: Element) -> None:
else:
raise ValueError
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self.normal and isinstance(self.normal, (Style, StyleUrl)):
pair = config.etree.SubElement( # type: ignore[attr-defined]
element,
diff --git a/fastkml/times.py b/fastkml/times.py
index 574a547d..ccca30f1 100644
--- a/fastkml/times.py
+++ b/fastkml/times.py
@@ -29,6 +29,7 @@
import fastkml.config as config
from fastkml.base import _BaseObject
from fastkml.enums import DateTimeResolution
+from fastkml.enums import Verbosity
from fastkml.types import Element
# regular expression to parse a gYearMonth string
@@ -167,8 +168,12 @@ def __init__(
super().__init__(ns=ns, id=id, target_id=target_id)
self.timestamp = timestamp
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
when = config.etree.SubElement( # type: ignore[attr-defined]
element, f"{self.ns}when"
)
@@ -208,8 +213,12 @@ def from_element(self, element: Element) -> None:
if end is not None:
self.end = KmlDateTime.parse(end.text)
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self.begin is not None:
text = str(self.begin)
if text:
diff --git a/fastkml/types.py b/fastkml/types.py
index ec614eb0..f5cc7b6d 100644
--- a/fastkml/types.py
+++ b/fastkml/types.py
@@ -37,7 +37,7 @@ def set(self, tag: str, value: str) -> None:
def get(self, tag: str) -> str:
"""Get the value of the tag."""
- def find(self, tag: str) -> "Element":
+ def find(self, tag: str) -> Optional["Element"]:
"""Find the first element with the given tag."""
def findall(self, tag: str) -> Iterable["Element"]:
diff --git a/fastkml/views.py b/fastkml/views.py
index 5b7d9e77..1fffea36 100644
--- a/fastkml/views.py
+++ b/fastkml/views.py
@@ -1,10 +1,12 @@
import logging
from typing import Optional
+from typing import SupportsFloat
from typing import Union
import fastkml.config as config
import fastkml.gx as gx
from fastkml.base import _BaseObject
+from fastkml.enums import Verbosity
from fastkml.mixins import TimeMixin
from fastkml.times import TimeSpan
from fastkml.times import TimeStamp
@@ -89,7 +91,7 @@ def longitude(self) -> Optional[float]:
return self._longitude
@longitude.setter
- def longitude(self, value) -> None:
+ def longitude(self, value: Optional[SupportsFloat]) -> None:
if isinstance(value, (str, int, float)) and (-180 <= float(value) <= 180):
self._longitude = float(value)
elif value is None:
@@ -102,7 +104,7 @@ def latitude(self) -> Optional[float]:
return self._latitude
@latitude.setter
- def latitude(self, value) -> None:
+ def latitude(self, value: Optional[SupportsFloat]) -> None:
if isinstance(value, (str, int, float)) and (-90 <= float(value) <= 90):
self._latitude = float(value)
elif value is None:
@@ -115,7 +117,7 @@ def altitude(self) -> Optional[float]:
return self._altitude
@altitude.setter
- def altitude(self, value) -> None:
+ def altitude(self, value: Optional[SupportsFloat]) -> None:
if isinstance(value, (str, int, float)):
self._altitude = float(value)
elif value is None:
@@ -128,7 +130,7 @@ def heading(self) -> Optional[float]:
return self._heading
@heading.setter
- def heading(self, value) -> None:
+ def heading(self, value: Optional[SupportsFloat]) -> None:
if isinstance(value, (str, int, float)) and (-180 <= float(value) <= 180):
self._heading = float(value)
elif value is None:
@@ -141,7 +143,7 @@ def tilt(self) -> Optional[float]:
return self._tilt
@tilt.setter
- def tilt(self, value) -> None:
+ def tilt(self, value: Optional[SupportsFloat]) -> None:
if isinstance(value, (str, int, float)) and (0 <= float(value) <= 180):
self._tilt = float(value)
elif value is None:
@@ -195,8 +197,12 @@ def from_element(self, element: Element):
s.from_element(timestamp)
self._timestamp = s
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self.longitude:
longitude = config.etree.SubElement(element, f"{self.ns}longitude")
longitude.text = str(self.longitude)
@@ -291,8 +297,12 @@ def from_element(self, element: Element) -> None:
if roll is not None:
self.roll = roll.text
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self.roll:
roll = config.etree.SubElement(element, f"{self.ns}roll")
roll.text = str(self.roll)
@@ -313,7 +323,6 @@ def roll(self, value) -> None:
class LookAt(_AbstractView):
-
__name__ = "LookAt"
_range: Optional[float] = None
@@ -367,8 +376,12 @@ def from_element(self, element: Element) -> None:
if range_var is not None:
self.range = range_var.text
- def etree_element(self) -> Element:
- element = super().etree_element()
+ def etree_element(
+ self,
+ precision: Optional[int] = None,
+ verbosity: Verbosity = Verbosity.normal,
+ ) -> Element:
+ element = super().etree_element(precision=precision, verbosity=verbosity)
if self.range:
range_var = config.etree.SubElement(element, f"{self.ns}range")
range_var.text = str(self._range)
diff --git a/pyproject.toml b/pyproject.toml
index 8ee0b917..a7216ab1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,46 +1,66 @@
+[tool.ruff.extend-per-file-ignores]
+"setup.py" = [
+ "E501",
+]
+"tests/oldunit_test.py" = [
+ "E501",
+]
+
[tool.isort]
-line_length = 88
force_single_line = true
+line_length = 88
[tool.flake8]
max_line_length = 89
-[tool.check-manifest]
-ignore = [".*", "mutmut_config.py", "test-requirements.txt", "tox.ini", "examples/*"]
-
-[tool.pyright]
-include = ["fastkml"]
-exclude = ["**/node_modules",
- "**/__pycache__",
- ".pytype",
- ".pyre",
-]
-
[tool.mypy]
disallow_any_generics = true
-disallow_untyped_calls = true
-disallow_untyped_defs = true
disallow_incomplete_defs = true
+disallow_untyped_calls = true
disallow_untyped_decorators = true
+disallow_untyped_defs = true
+enable_error_code = [
+ "ignore-without-code",
+]
ignore_errors = false
ignore_missing_imports = true
implicit_reexport = false
-strict_optional = true
-strict_equality = true
no_implicit_optional = true
-warn_unused_ignores = true
-warn_redundant_casts = true
-warn_unused_configs = true
-warn_unreachable = true
+show_error_codes = true
+strict_equality = true
+strict_optional = true
warn_no_return = true
+warn_redundant_casts = true
warn_return_any = true
-show_error_codes = true
-
-# mypy per-module options:
+warn_unreachable = true
+warn_unused_configs = true
+warn_unused_ignores = true
[[tool.mypy.overrides]]
+ignore_errors = true
module = [
- "fastkml.kml", "fastkml.views",
- "fastkml.tests.oldunit_test", "fastkml.tests.config_test"
+ "fastkml.kml",
+ "fastkml.tests.config_test",
+ "fastkml.tests.oldunit_test",
+ "fastkml.views",
+]
+
+[tool.check-manifest]
+ignore = [
+ ".*",
+ "examples/*",
+ "mutmut_config.py",
+ "test-requirements.txt",
+ "tox.ini",
+]
+
+[tool.pyright]
+exclude = [
+ "**/__pycache__",
+ "**/node_modules",
+ ".pyre",
+ ".pytype",
+]
+include = [
+ "fastkml",
]
-ignore_errors = true
diff --git a/requirements.txt b/requirements.txt
index ad28f0e3..c7b6a580 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
# Base package requirements
-pygeoif>=1.0.0
+pygeoif>=1.1.0
python-dateutil
+typing_extensions
diff --git a/schema/ogckml22.xsd b/schema/ogckml22.xsd
new file mode 100644
index 00000000..3496aef5
--- /dev/null
+++ b/schema/ogckml22.xsd
@@ -0,0 +1,1642 @@
+
+
+
+
+ ogckml22.xsd 2008-01-23
+ XML Schema Document for OGC KML version 2.2. Copyright (c)
+ 2008 Open Geospatial Consortium.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ not anyURI due to $[x] substitution in
+ PhotoOverlay
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Snippet deprecated in 2.2
+
+
+
+
+
+
+
+
+
+
+
+
+ Metadata deprecated in 2.2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Metadata deprecated in 2.2
+
+
+
+
+
+ MetadataType deprecated in 2.2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ is the root element.
+
+ ]]>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Url deprecated in 2.2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Url deprecated in 2.2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ color deprecated in 2.1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/setup.py b/setup.py
index 5bfb0fe7..ca715166 100644
--- a/setup.py
+++ b/setup.py
@@ -22,7 +22,7 @@ def run_tests(self) -> None:
setup(
name="fastkml",
- version="1.0.alpha.4",
+ version="1.0.alpha.6",
description="Fast KML processing in python",
long_description=(
open("README.rst").read()
@@ -39,12 +39,13 @@ def run_tests(self) -> None:
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
"Intended Audience :: Developers",
"License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)",
# "Development Status :: 5 - Production/Stable",
"Development Status :: 3 - Alpha",
"Operating System :: OS Independent",
- ], # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers
+ ],
keywords="GIS KML Google Maps OpenLayers",
author="Christian Ledermann",
author_email="christian.ledermann@gmail.com",
@@ -58,10 +59,10 @@ def run_tests(self) -> None:
python_requires=">=3.7",
install_requires=[
# -*- Extra requirements: -*-
- "pygeoif>=1.0.0",
+ "pygeoif>=1.1.0",
"python-dateutil",
"setuptools",
- "typing_extensions",
+ "typing-extensions",
],
entry_points="""
# -*- Entry points: -*-
diff --git a/tests/atom_test.py b/tests/atom_test.py
index 223cdfee..09e98fdd 100644
--- a/tests/atom_test.py
+++ b/tests/atom_test.py
@@ -26,14 +26,14 @@ class TestStdLibrary(StdLibrary):
def test_atom_link_ns(self) -> None:
ns = "{http://www.opengis.net/kml/2.2}" # noqa: FS003
- l = atom.Link(ns=ns)
- assert l.ns == ns
- assert l.to_string().startswith(
+ link = atom.Link(ns=ns)
+ assert link.ns == ns
+ assert link.to_string().startswith(
' None:
- l = atom.Link(
+ link = atom.Link(
href="#here",
rel="alternate",
type="text/html",
@@ -42,7 +42,7 @@ def test_atom_link(self) -> None:
length=3456,
)
- serialized = l.to_string()
+ serialized = link.to_string()
assert ' None:
assert 'length="3456"' in serialized
def test_atom_link_read(self) -> None:
- l = atom.Link()
- l.from_string(
+ link = atom.Link()
+ link.from_string(
''
)
- assert l.href == "#here"
- assert l.rel == "alternate"
- assert l.type == "text/html"
- assert l.hreflang == "en"
- assert l.title == "Title"
- assert l.length == 3456
+ assert link.href == "#here"
+ assert link.rel == "alternate"
+ assert link.type == "text/html"
+ assert link.hreflang == "en"
+ assert link.title == "Title"
+ assert link.length == 3456
def test_atom_link_read_no_href(self) -> None:
- l = atom.Link()
- l.from_string(
+ link = atom.Link()
+ link.from_string(
''
)
- assert l.href is None
+ assert link.href is None
def test_atom_person_ns(self) -> None:
ns = "{http://www.opengis.net/kml/2.2}" # noqa: FS003
@@ -133,14 +133,14 @@ def test_author_roundtrip(self) -> None:
assert a.to_string() == a2.to_string()
def test_link_roundtrip(self) -> None:
- l = atom.Link(href="http://localhost/", rel="alternate")
- l.title = "Title"
- l.type = "text/html"
- l.hreflang = "en"
- l.length = 4096
+ link = atom.Link(href="http://localhost/", rel="alternate")
+ link.title = "Title"
+ link.type = "text/html"
+ link.hreflang = "en"
+ link.length = 4096
l2 = atom.Link()
- l2.from_string(l.to_string())
- assert l.to_string() == l2.to_string()
+ l2.from_string(link.to_string())
+ assert link.to_string() == l2.to_string()
class TestLxml(Lxml, TestStdLibrary):
diff --git a/tests/base_test.py b/tests/base_test.py
index 5637dc8d..d6362198 100644
--- a/tests/base_test.py
+++ b/tests/base_test.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2021 Christian Ledermann
+# Copyright (C) 2021 - 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
@@ -33,17 +33,28 @@ def test_to_string(self) -> None:
obj = base._BaseObject(id="id-0", target_id="target-id-0")
obj.__name__ = "test"
- assert (
- obj.to_string()
- == ''
+ assert obj.to_string() == (
+ ''
)
+ def test_to_str_empty_ns(self) -> None:
+ obj = base._BaseObject(ns="", id="id-0", target_id="target-id-0")
+ obj.__name__ = "test"
+
+ assert obj.to_string().replace(" ", "").replace(
+ "\n", ""
+ ) == ''.replace(" ", "")
+
def test_from_string(self) -> None:
be = base._BaseObject()
be.__name__ = "test"
be.from_string(
- xml_string=''
+ xml_string=(
+ ''
+ )
)
assert be.id == "id-0"
@@ -64,7 +75,10 @@ class Test(base._BaseObject):
def test_base_from_element_raises(self) -> None:
be = base._BaseObject()
- element = cast(types.Element, config.etree.Element(config.KMLNS + "Base")) # type: ignore[attr-defined]
+ element = cast(
+ types.Element,
+ config.etree.Element(config.KMLNS + "Base"), # type: ignore[attr-defined]
+ )
with pytest.raises(TypeError):
be.from_element(element=element)
@@ -74,9 +88,23 @@ def test_base_from_string_raises(self) -> None:
with pytest.raises(TypeError):
be.from_string(
- xml_string=''
+ ''
)
+ def test_base_class_from_string(self) -> None:
+ be = base._BaseObject.class_from_string('')
+
+ assert be.id == "id-0"
+ assert be.target_id == "td-00"
+ assert be.ns == "{http://www.opengis.net/kml/2.2}"
+
+ def test_base_class_from_empty_string(self) -> None:
+ be = base._BaseObject.class_from_string("")
+
+ assert be.id == ""
+ assert be.target_id == ""
+ assert be.ns == "{http://www.opengis.net/kml/2.2}"
+
class TestLxml(Lxml, TestStdLibrary):
"""Test the base object with lxml."""
@@ -95,7 +123,10 @@ def test_from_string(self) -> None:
be.__name__ = "test"
be.from_string(
- xml_string='\n'
+ xml_string=(
+ '\n'
+ )
)
assert be.id == "id-0"
diff --git a/tests/data_test.py b/tests/data_test.py
new file mode 100644
index 00000000..adad5f7b
--- /dev/null
+++ b/tests/data_test.py
@@ -0,0 +1,203 @@
+# Copyright (C) 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
+# 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 gx classes."""
+import pytest
+
+import fastkml as kml
+from fastkml import data
+from tests.base import Lxml
+from tests.base import StdLibrary
+
+
+class TestStdLibrary(StdLibrary):
+ """Test with the standard library."""
+
+ def test_schema(self) -> None:
+ ns = "{http://www.opengis.net/kml/2.2}" # noqa: FS003
+ pytest.raises(ValueError, kml.Schema, ns)
+ s = kml.Schema(ns, "some_id")
+ assert not list(s.simple_fields)
+ s.append("int", "Integer", "An Integer")
+ assert list(s.simple_fields)[0]["type"] == "int"
+ assert list(s.simple_fields)[0]["name"] == "Integer"
+ assert list(s.simple_fields)[0]["displayName"] == "An Integer"
+ s.simple_fields = None
+ assert not list(s.simple_fields)
+ pytest.raises(TypeError, s.append, ("none", "Integer", "An Integer"))
+ # pytest.raises(
+ # TypeError, s.simple_fields, ("none", "Integer", "An Integer"),
+ # )
+ # pytest.raises(TypeError, s.simple_fields, ("int", "Integer", "An Integer"))
+ fields = {"type": "int", "name": "Integer", "display_name": "An Integer"}
+ s.simple_fields = fields
+ assert list(s.simple_fields)[0]["type"] == "int"
+ assert list(s.simple_fields)[0]["name"] == "Integer"
+ assert list(s.simple_fields)[0]["displayName"] == "An Integer"
+ s.simple_fields = [["float", "Float"], fields]
+ assert list(s.simple_fields)[0]["type"] == "int"
+ assert list(s.simple_fields)[0]["name"] == "Integer"
+ assert list(s.simple_fields)[0]["displayName"] == "An Integer"
+ assert list(s.simple_fields)[1]["type"] == "float"
+ assert list(s.simple_fields)[1]["name"] == "Float"
+ assert list(s.simple_fields)[1]["displayName"] is None
+
+ def test_schema_data(self) -> None:
+ ns = "{http://www.opengis.net/kml/2.2}" # noqa: FS003
+ pytest.raises(ValueError, data.SchemaData, ns)
+ pytest.raises(ValueError, data.SchemaData, ns, "")
+ sd = data.SchemaData(ns, "#default")
+ sd.append_data("text", "Some Text")
+ assert len(sd.data) == 1
+ sd.append_data(value=1, name="Integer")
+ assert len(sd.data) == 2
+ assert sd.data[0] == {"value": "Some Text", "name": "text"}
+ assert sd.data[1] == {"value": 1, "name": "Integer"}
+ new_data = (("text", "Some new Text"), {"value": 2, "name": "Integer"})
+ sd.data = new_data
+ assert len(sd.data) == 2
+ assert sd.data[0] == {"value": "Some new Text", "name": "text"}
+ assert sd.data[1] == {"value": 2, "name": "Integer"}
+
+ def test_untyped_extended_data(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.extended_data = kml.ExtendedData(
+ ns=ns,
+ elements=[
+ data.Data(ns=ns, name="info", value="so much to see"),
+ data.Data(
+ ns=ns, name="weather", display_name="Weather", value="blue skies"
+ ),
+ ],
+ )
+
+ assert len(p.extended_data.elements) == 2
+ k.append(p)
+
+ k2 = kml.KML()
+ k2.from_string(k.to_string(prettyprint=True))
+ k.to_string()
+
+ extended_data = list(k2.features())[0].extended_data
+ assert extended_data is not None
+ assert len(extended_data.elements), 2
+ assert extended_data.elements[0].name == "info"
+ assert extended_data.elements[0].value == "so much to see"
+ assert extended_data.elements[0].display_name is None
+ assert extended_data.elements[1].name == "weather"
+ assert extended_data.elements[1].value == "blue skies"
+ assert extended_data.elements[1].display_name == "Weather"
+
+ def test_untyped_extended_data_nested(self) -> None:
+ ns = "{http://www.opengis.net/kml/2.2}" # noqa: FS003
+ k = kml.KML(ns=ns)
+
+ d = kml.Document(ns, "docid", "doc name", "doc description")
+ d.extended_data = kml.ExtendedData(
+ ns=ns, elements=[data.Data(ns=ns, name="type", value="Document")]
+ )
+
+ f = kml.Folder(ns, "fid", "f name", "f description")
+ f.extended_data = kml.ExtendedData(
+ ns=ns, elements=[data.Data(ns=ns, name="type", value="Folder")]
+ )
+
+ k.append(d)
+ d.append(f)
+
+ k2 = kml.KML()
+ k2.from_string(k.to_string())
+
+ document_data = list(k2.features())[0].extended_data
+ folder_data = list(list(k2.features())[0].features())[0].extended_data
+
+ assert document_data.elements[0].name == "type"
+ assert document_data.elements[0].value == "Document"
+
+ assert folder_data.elements[0].name == "type"
+ assert folder_data.elements[0].value == "Folder"
+
+ def test_extended_data(self) -> None:
+ doc = """
+
+ Simple placemark
+
+
+ -122.0822035425683,37.42228990140251,0
+
+
+
+ This is hole
+ ]]>
+ 1
+
+
+ The par for this hole is
+ ]]>
+ 4
+
+
+ Mount Everest
+ 347.45
+ 10000
+
+
+
+ """
+
+ k = kml.KML()
+ k.from_string(doc)
+
+ extended_data = list(k.features())[0].extended_data
+
+ assert extended_data.elements[0].name == "holeNumber"
+ assert extended_data.elements[0].value == "1"
+ assert "This is hole " in extended_data.elements[0].display_name
+
+ assert extended_data.elements[1].name == "holePar"
+ assert extended_data.elements[1].value == "4"
+ assert (
+ "The par for this hole is " in extended_data.elements[1].display_name
+ )
+ sd = extended_data.elements[2]
+ assert sd.data[0]["name"] == "TrailHeadName"
+ assert sd.data[1]["value"] == "347.45"
+
+ def test_schema_data_from_str(self) -> None:
+ doc = """
+ Pi in the sky
+ 3.14159
+ 10
+ """
+
+ sd = data.SchemaData(ns="", schema_url="#default")
+ sd.from_string(doc)
+ assert sd.schema_url == "#TrailHeadTypeId"
+ assert sd.data[0] == {"name": "TrailHeadName", "value": "Pi in the sky"}
+ assert sd.data[1] == {"name": "TrailLength", "value": "3.14159"}
+ assert sd.data[2] == {"name": "ElevationGain", "value": "10"}
+ sd1 = data.SchemaData(ns="", schema_url="#default")
+ sd1.from_string(sd.to_string())
+ assert sd1.schema_url == "#TrailHeadTypeId"
+ assert sd.to_string() == sd1.to_string()
+
+
+class TestLxml(Lxml, TestStdLibrary):
+ """Test with lxml."""
diff --git a/tests/geometries/__init__.py b/tests/geometries/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/geometries/geometry_test.py b/tests/geometries/geometry_test.py
new file mode 100644
index 00000000..19d4528f
--- /dev/null
+++ b/tests/geometries/geometry_test.py
@@ -0,0 +1,396 @@
+# Copyright (C) 2021 - 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
+# 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 geometry classes."""
+
+from fastkml.geometry import AltitudeMode
+from fastkml.geometry import LinearRing
+from fastkml.geometry import LineString
+from fastkml.geometry import MultiGeometry
+from fastkml.geometry import Point
+from fastkml.geometry import Polygon
+from fastkml.geometry import _Geometry
+from tests.base import Lxml
+from tests.base import StdLibrary
+
+
+class TestGetGeometry(StdLibrary):
+ def test_altitude_mode(self) -> None:
+ doc = """
+ 0.000000,1.000000
+ clampToGround
+ """
+
+ g = Point.class_from_string(doc)
+
+ assert g.altitude_mode == AltitudeMode("clampToGround")
+
+ def test_extrude(self) -> None:
+ doc = """
+ 0.000000,1.000000
+ 1
+ """
+
+ g = Point.class_from_string(doc)
+
+ assert g.extrude is True
+
+ def test_tesselate(self) -> None:
+ doc = """
+ 0.000000,1.000000
+ 1
+ """
+
+ g = Point.class_from_string(doc)
+
+ assert g.tessellate is True
+
+ def test_point(self) -> None:
+ doc = """
+ 0.000000,1.000000
+ """
+
+ g = Point.class_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) -> None:
+ doc = """
+ 0.000000,0.000000 1.000000,1.000000
+ """
+
+ g = LineString.class_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) -> None:
+ doc = """
+ 0.000000,0.000000 1.000000,0.000000 1.000000,1.000000
+ 0.000000,0.000000
+
+ """
+
+ g = LinearRing.class_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) -> None:
+ doc = """
+
+
+ 0.000000,0.000000 1.000000,0.000000 1.000000,1.000000
+ 0.000000,0.000000
+
+
+
+ """
+
+ g = Polygon.class_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)),),
+ }
+
+ def test_polygon_with_inner_boundary(self) -> None:
+ 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 = Polygon.class_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) -> None:
+ doc = """
+
+
+ 0.000000,1.000000
+
+
+ 1.000000,1.000000
+
+
+ """
+
+ g = MultiGeometry.class_from_string(doc)
+
+ assert len(g.geometry) == 2
+
+ def test_multilinestring(self) -> None:
+ doc = """
+
+
+ 0.000000,0.000000 1.000000,0.000000
+
+
+ 0.000000,1.000000 1.000000,1.000000
+
+
+ """
+
+ g = MultiGeometry.class_from_string(doc)
+
+ assert len(g.geometry) == 2
+
+ def test_multipolygon(self) -> None:
+ 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 = MultiGeometry.class_from_string(doc)
+
+ assert len(g.geometry) == 2
+
+ def test_geometrycollection(self) -> None:
+ 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 = MultiGeometry.class_from_string(doc)
+
+ assert len(g.geometry) == 4
+
+ def test_geometrycollection_with_linearring(self) -> None:
+ 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 = MultiGeometry.class_from_string(doc)
+
+ assert len(g.geometry) == 2
+ assert g.geometry.geom_type == "GeometryCollection"
+
+
+class TestGeometry(StdLibrary):
+ """Test the _Geometry class."""
+
+ def test_init(self) -> None:
+ """Test the init method."""
+ g = _Geometry()
+
+ assert g.ns == "{http://www.opengis.net/kml/2.2}"
+ assert g.target_id is None
+ assert g.id is None
+ assert g.extrude is False
+ assert g.altitude_mode is None
+ assert g.tessellate is False
+
+ def test_init_with_args(self) -> None:
+ """Test the init method with arguments."""
+ g = _Geometry(
+ ns="",
+ target_id="target_id",
+ id="id",
+ extrude=True,
+ altitude_mode=AltitudeMode.clamp_to_ground,
+ tessellate=True,
+ )
+
+ assert g.ns == ""
+ assert g.target_id == "target_id"
+ assert g.id == "id"
+ assert g.extrude is True
+ assert g.altitude_mode == AltitudeMode.clamp_to_ground
+ assert g.tessellate is True
+
+ def test_to_string(self) -> None:
+ """Test the to_string method."""
+ g = _Geometry()
+
+ assert "http://www.opengis.net/kml/2.2" in g.to_string()
+ assert "targetId=" not in g.to_string()
+ assert "id=" not in g.to_string()
+ assert "extrude>0" in g.to_string()
+ assert "altitudeMode" not in g.to_string()
+ assert "tessellate>0<" in g.to_string()
+
+ def test_to_string_with_args(self) -> None:
+ """Test the to_string method."""
+ g = _Geometry(
+ ns="{http://www.opengis.net/kml/2.3}",
+ target_id="target_id",
+ id="my-id",
+ extrude=True,
+ altitude_mode=AltitudeMode.relative_to_ground,
+ tessellate=True,
+ )
+
+ assert "http://www.opengis.net/kml/2.3" in g.to_string()
+ assert 'targetId="target_id"' in g.to_string()
+ assert 'id="my-id"' in g.to_string()
+ assert "extrude>1" in g.to_string()
+ assert "altitudeMode>relativeToGround<" in g.to_string()
+ assert "tessellate>1<" in g.to_string()
+ # assert not g.to_string()
+
+ def test_from_string(self) -> None:
+ """Test the from_string method."""
+ g = _Geometry.class_from_string(
+ '<_Geometry id="my-id" targetId="target_id">'
+ "1"
+ "relativeToGround"
+ "1"
+ "",
+ ns="",
+ )
+
+ assert g.ns == ""
+ assert g.target_id == "target_id"
+ assert g.id == "my-id"
+ assert g.extrude is True
+ assert g.altitude_mode == AltitudeMode.relative_to_ground
+ assert g.tessellate is True
+
+ def test_from_string_invalid_altitude_mode(self) -> None:
+ """Test the from_string method."""
+ g = _Geometry.class_from_string(
+ '<_Geometry id="my-id" targetId="target_id">'
+ "relative"
+ "",
+ ns="",
+ )
+
+ assert g.altitude_mode is None
+
+ def test_from_string_invalid_extrude(self) -> None:
+ """Test the from_string method."""
+ g = _Geometry.class_from_string(
+ '<_Geometry id="my-id" targetId="target_id">'
+ "true"
+ "",
+ ns="",
+ )
+
+ assert g.extrude is None
+
+ def test_from_minimal_string(self) -> None:
+ g = _Geometry.class_from_string(
+ "<_Geometry/>",
+ ns="",
+ )
+
+ assert g.ns == ""
+ assert g.target_id == ""
+ assert g.id == ""
+ assert g.extrude is None
+ assert g.altitude_mode is None
+ assert g.tessellate is None
+
+ def test_from_string_omitting_ns(self) -> None:
+ """Test the from_string method."""
+ g = _Geometry.class_from_string(
+ ''
+ "1"
+ "relativeToGround"
+ "1"
+ "",
+ )
+
+ assert g.ns == "{http://www.opengis.net/kml/2.2}"
+ assert g.target_id == "target_id"
+ assert g.id == "my-id"
+ assert g.extrude is True
+ assert g.altitude_mode == AltitudeMode.relative_to_ground
+ assert g.tessellate is True
+
+
+class TestGetGeometryLxml(Lxml, TestGetGeometry):
+ """Test with lxml."""
+
+
+class TestGeometryLxml(Lxml, TestGeometry):
+ """Test with lxml."""
diff --git a/tests/geometries/linearring_test.py b/tests/geometries/linearring_test.py
new file mode 100644
index 00000000..979a6b75
--- /dev/null
+++ b/tests/geometries/linearring_test.py
@@ -0,0 +1,66 @@
+# Copyright (C) 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
+# 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 geometry classes."""
+from typing import cast
+
+import pygeoif.geometry as geo
+
+from fastkml.geometry import LinearRing
+from tests.base import Lxml
+from tests.base import StdLibrary
+
+
+class TestLinearRing(StdLibrary):
+ def test_init(self) -> None:
+ """Test the init method."""
+ lr = geo.LinearRing(((0, 0), (0, 1), (1, 1), (1, 0), (0, 0)))
+
+ linear_ring = LinearRing(geometry=lr)
+
+ assert linear_ring.geometry == lr
+ assert linear_ring.altitude_mode is None
+ assert linear_ring.extrude is False
+
+ def test_to_string(self) -> None:
+ """Test the to_string method."""
+ lr = geo.LinearRing(((0, 0), (0, 1), (1, 1), (1, 0), (0, 0)))
+
+ linear_ring = LinearRing(geometry=lr)
+
+ assert "LinearRing" in linear_ring.to_string()
+ assert (
+ "coordinates>0.000000,0.000000 0.000000,1.000000 1.000000,1.000000 "
+ "1.000000,0.000000 0.000000,0.000000" in linear_ring.to_string()
+ )
+
+ def test_from_string(self) -> None:
+ """Test the from_string method."""
+ linear_ring = cast(
+ LinearRing,
+ LinearRing.class_from_string(
+ ''
+ "0.000000,0.000000 1.000000,0.000000 1.0,1.0 "
+ "0.000000,0.000000"
+ ""
+ ),
+ )
+
+ assert linear_ring.geometry == geo.LinearRing(((0, 0), (1, 0), (1, 1), (0, 0)))
+
+
+class TestLinearRingLxml(Lxml, TestLinearRing):
+ """Test with lxml."""
diff --git a/tests/geometries/linestring_test.py b/tests/geometries/linestring_test.py
new file mode 100644
index 00000000..e1351686
--- /dev/null
+++ b/tests/geometries/linestring_test.py
@@ -0,0 +1,71 @@
+# Copyright (C) 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
+# 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 geometry classes."""
+from typing import cast
+
+import pygeoif.geometry as geo
+
+from fastkml.geometry import LineString
+from tests.base import Lxml
+from tests.base import StdLibrary
+
+
+class TestLineString(StdLibrary):
+ def test_init(self) -> None:
+ """Test the init method."""
+ ls = geo.LineString(((1, 2), (2, 0)))
+
+ line_string = LineString(geometry=ls)
+
+ assert line_string.geometry == ls
+ assert line_string.altitude_mode is None
+ assert line_string.extrude is False
+
+ def test_to_string(self) -> None:
+ """Test the to_string method."""
+ ls = geo.LineString(((1, 2), (2, 0)))
+
+ line_string = LineString(geometry=ls)
+
+ assert "LineString" in line_string.to_string()
+ assert (
+ "coordinates>1.000000,2.000000 2.000000,0.000000"
+ in line_string.to_string()
+ )
+
+ def test_from_string(self) -> None:
+ """Test the from_string method."""
+ linestring = cast(
+ LineString,
+ LineString.class_from_string(
+ ''
+ "1"
+ "1"
+ ""
+ "-122.364383,37.824664,0 -122.364152,37.824322,0"
+ ""
+ ""
+ ),
+ )
+
+ assert linestring.geometry == geo.LineString(
+ ((-122.364383, 37.824664, 0), (-122.364152, 37.824322, 0))
+ )
+
+
+class TestLineStringLxml(Lxml, TestLineString):
+ """Test with lxml."""
diff --git a/tests/geometries/multigeometry_test.py b/tests/geometries/multigeometry_test.py
new file mode 100644
index 00000000..00c26437
--- /dev/null
+++ b/tests/geometries/multigeometry_test.py
@@ -0,0 +1,374 @@
+# Copyright (C) 2021 - 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
+# 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 geometry classes."""
+import pygeoif.geometry as geo
+
+from fastkml.geometry import MultiGeometry
+from tests.base import Lxml
+from tests.base import StdLibrary
+
+
+class TestMultiPointStdLibrary(StdLibrary):
+ """Test with the standard library."""
+
+ def test_1_point(self):
+ """Test with one point."""
+ p = geo.MultiPoint([(1, 2)])
+
+ mg = MultiGeometry(ns="", geometry=p)
+
+ assert "coordinates>1.000000,2.000000" in mg.to_string()
+ assert "MultiGeometry>" in mg.to_string()
+ assert "Point>" in mg.to_string()
+
+ def test_2_points(self):
+ """Test with two points."""
+ p = geo.MultiPoint([(1, 2), (3, 4)])
+
+ mg = MultiGeometry(ns="", geometry=p)
+
+ assert "coordinates>1.000000,2.000000" in mg.to_string()
+ assert "coordinates>3.000000,4.000000" in mg.to_string()
+ assert "MultiGeometry>" in mg.to_string()
+ assert "Point>" in mg.to_string()
+
+ def test_2_points_read(self) -> None:
+ xml = (
+ "00"
+ "1.000000,2.000000"
+ "3.000000,4.000000"
+ )
+
+ mg = MultiGeometry.class_from_string(xml, ns="")
+
+ assert mg.geometry == geo.MultiPoint([(1, 2), (3, 4)])
+
+
+class TestMultiLineStringStdLibrary(StdLibrary):
+ def test_1_linestring(self):
+ """Test with one linestring."""
+ p = geo.MultiLineString([[(1, 2), (3, 4)]])
+
+ mg = MultiGeometry(ns="", geometry=p)
+
+ assert "coordinates>1.000000,2.000000 3.000000,4.000000" in mg.to_string()
+ assert "MultiGeometry>" in mg.to_string()
+ assert "LineString>" in mg.to_string()
+
+ def test_2_linestrings(self):
+ """Test with two linestrings."""
+ p = geo.MultiLineString([[(1, 2), (3, 4)], [(5, 6), (7, 8)]])
+
+ mg = MultiGeometry(ns="", geometry=p)
+
+ assert "coordinates>1.000000,2.000000 3.000000,4.000000" in mg.to_string()
+ assert "coordinates>5.000000,6.000000 7.000000,8.000000" in mg.to_string()
+ assert "MultiGeometry>" in mg.to_string()
+ assert "LineString>" in mg.to_string()
+
+ 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"
+ ""
+ )
+
+ mg = MultiGeometry.class_from_string(xml, ns="")
+
+ assert mg.geometry == geo.MultiLineString([[(1, 2), (3, 4)], [(5, 6), (7, 8)]])
+
+
+class TestMultiPolygonStdLibrary(StdLibrary):
+ def test_1_polygon(self):
+ """Test with one polygon."""
+ p = geo.MultiPolygon([[[[1, 2], [3, 4], [5, 6], [1, 2]]]])
+
+ mg = MultiGeometry(ns="", geometry=p)
+
+ assert (
+ "coordinates>1.000000,2.000000 3.000000,4.000000 5.000000,6.000000 "
+ "1.000000,2.000000" in mg.to_string()
+ )
+ assert "MultiGeometry>" in mg.to_string()
+ assert "Polygon>" in mg.to_string()
+ assert "outerBoundaryIs>" in mg.to_string()
+ assert "innerBoundaryIs>" not in mg.to_string()
+
+ def test_1_polygons_with_holes(self):
+ """Test with one polygon with holes."""
+ p = geo.MultiPolygon(
+ [
+ (
+ ((0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0)),
+ [((0.25, 0.25), (0.25, 0.5), (0.5, 0.5), (0.5, 0.25))],
+ ),
+ ],
+ )
+ mg = MultiGeometry(ns="", 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()
+ assert "outerBoundaryIs>" in mg.to_string()
+ assert "innerBoundaryIs>" in mg.to_string()
+
+ def test_2_polygons(self):
+ """Test with two polygons."""
+ p = geo.MultiPolygon(
+ [
+ (
+ ((0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0)),
+ [((0.1, 0.1), (0.1, 0.2), (0.2, 0.2), (0.2, 0.1))],
+ ),
+ (((0.0, 0.0), (0.0, 2.0), (1.0, 1.0), (1.0, 0.0)),),
+ ],
+ )
+
+ mg = MultiGeometry(ns="", 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()
+ assert "outerBoundaryIs>" in mg.to_string()
+ assert "innerBoundaryIs>" in mg.to_string()
+
+ def test_2_polygons_read(self) -> None:
+ xml = (
+ "00"
+ ""
+ "0.000000,0.000000 0.000000,1.000000 1.000000,1.000000 "
+ "1.000000,0.000000 0.000000,0.000000"
+ ""
+ ""
+ "0.100000,0.100000 0.100000,0.200000 0.200000,0.200000 "
+ "0.200000,0.100000 0.100000,0.100000"
+ ""
+ ""
+ "0.000000,0.000000 0.000000,2.000000 1.000000,1.000000 "
+ "1.000000,0.000000 0.000000,0.000000"
+ ""
+ )
+
+ mg = MultiGeometry.class_from_string(xml, ns="")
+
+ assert mg.geometry == geo.MultiPolygon(
+ [
+ (
+ ((0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0)),
+ [((0.1, 0.1), (0.1, 0.2), (0.2, 0.2), (0.2, 0.1))],
+ ),
+ (((0.0, 0.0), (0.0, 2.0), (1.0, 1.0), (1.0, 0.0)),),
+ ],
+ )
+
+
+class TestGeometryCollectionStdLibrary(StdLibrary):
+ """Test heterogeneous geometry collections."""
+
+ def test_1_point(self):
+ """Test with one point."""
+ p = geo.GeometryCollection([geo.Point(1, 2)])
+
+ mg = MultiGeometry(ns="", geometry=p)
+
+ assert "coordinates>1.000000,2.000000" in mg.to_string()
+ assert "MultiGeometry>" in mg.to_string()
+ assert "Point>" in mg.to_string()
+
+ def test_geometries(self):
+ p = geo.Point(1, 2)
+ ls = geo.LineString(((1, 2), (2, 0)))
+ lr = geo.LinearRing(((0, 0), (0, 1), (1, 1), (1, 0), (0, 0)))
+ poly = geo.Polygon(
+ [(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)],
+ [[(0.1, 0.1), (0.1, 0.9), (0.9, 0.9), (0.9, 0.1), (0.1, 0.1)]],
+ )
+ gc = geo.GeometryCollection([p, ls, lr, poly])
+
+ mg = MultiGeometry(ns="", geometry=gc)
+
+ assert "Point>" in mg.to_string()
+ assert "LineString>" in mg.to_string()
+ assert "LinearRing>" in mg.to_string()
+ assert "Polygon>" in mg.to_string()
+ assert "MultiGeometry>" in mg.to_string()
+
+ def test_multi_geometries(self):
+ p = geo.Point(1, 2)
+ ls = geo.LineString(((1, 2), (2, 0)))
+ lr = geo.LinearRing(((0, 0), (0, 1), (1, 1), (1, 0), (0, 0)))
+ poly = geo.Polygon(
+ [(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)],
+ [[(0.1, 0.1), (0.1, 0.9), (0.9, 0.9), (0.9, 0.1), (0.1, 0.1)]],
+ )
+ gc = geo.GeometryCollection([p, ls, lr, poly])
+ mp = geo.MultiPolygon(
+ [
+ (
+ ((0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0)),
+ [((0.1, 0.1), (0.1, 0.2), (0.2, 0.2), (0.2, 0.1))],
+ ),
+ (((0.0, 0.0), (0.0, 2.0), (1.0, 1.0), (1.0, 0.0)),),
+ ],
+ )
+ ml = geo.MultiLineString([[(1, 2), (3, 4)], [(5, 6), (7, 8)]])
+ mgc = geo.GeometryCollection([gc, mp, ml])
+ mg = MultiGeometry(ns="", geometry=mgc)
+
+ assert "Point>" in mg.to_string()
+ assert "LineString>" in mg.to_string()
+ assert "LinearRing>" in mg.to_string()
+ assert "Polygon>" in mg.to_string()
+ assert "MultiGeometry>" in mg.to_string()
+
+ def test_multi_geometries_read(self) -> None:
+ xml = (
+ "00"
+ "1.000000,2.000000"
+ "1.000000,2.000000 2.000000,0.000000"
+ "0.000000,0.000000 0.000000,1.000000 "
+ "1.000000,1.000000 1.000000,0.000000 0.000000,0.000000"
+ ""
+ "0.000000,0.000000 0.000000,1.000000 1.000000,1.000000 "
+ "1.000000,0.000000 0.000000,0.000000"
+ ""
+ "0.100000,0.100000 0.100000,0.900000 0.900000,0.900000 "
+ "0.900000,0.100000 0.100000,0.100000"
+ ""
+ ""
+ "0.000000,0.000000 0.000000,1.000000 1.000000,1.000000 "
+ "1.000000,0.000000 0.000000,0.000000"
+ ""
+ "0.100000,0.100000 0.100000,0.200000 0.200000,0.200000 "
+ "0.200000,0.100000 0.100000,0.100000"
+ ""
+ ""
+ "0.000000,0.000000 0.000000,2.000000 1.000000,1.000000 "
+ "1.000000,0.000000 0.000000,0.000000"
+ ""
+ "1.000000,2.000000 3.000000,4.000000"
+ "5.000000,6.000000 7.000000,8.000000"
+ ""
+ )
+
+ mg = MultiGeometry.class_from_string(xml, ns="")
+
+ assert mg.geometry == geo.GeometryCollection(
+ (
+ geo.GeometryCollection(
+ (
+ geo.Point(1.0, 2.0),
+ geo.LineString(((1.0, 2.0), (2.0, 0.0))),
+ geo.Polygon(
+ (
+ (0.0, 0.0),
+ (0.0, 1.0),
+ (1.0, 1.0),
+ (1.0, 0.0),
+ (0.0, 0.0),
+ ),
+ (
+ (
+ (0.1, 0.1),
+ (0.1, 0.9),
+ (0.9, 0.9),
+ (0.9, 0.1),
+ (0.1, 0.1),
+ ),
+ ),
+ ),
+ geo.LinearRing(
+ ((0.0, 0.0), (0.0, 1.0), (1.0, 1.0), (1.0, 0.0), (0.0, 0.0))
+ ),
+ )
+ ),
+ geo.MultiPolygon(
+ (
+ (
+ (
+ (0.0, 0.0),
+ (0.0, 1.0),
+ (1.0, 1.0),
+ (1.0, 0.0),
+ (0.0, 0.0),
+ ),
+ (
+ (
+ (0.1, 0.1),
+ (0.1, 0.2),
+ (0.2, 0.2),
+ (0.2, 0.1),
+ (0.1, 0.1),
+ ),
+ ),
+ ),
+ (((0.0, 0.0), (0.0, 2.0), (1.0, 1.0), (1.0, 0.0), (0.0, 0.0)),),
+ )
+ ),
+ geo.MultiLineString(
+ (((1.0, 2.0), (3.0, 4.0)), ((5.0, 6.0), (7.0, 8.0)))
+ ),
+ )
+ )
+
+ def test_empty_multi_geometries_read(self) -> None:
+ xml = (
+ "00"
+ ""
+ )
+
+ mg = MultiGeometry.class_from_string(xml, ns="")
+
+ assert mg.geometry is None
+ assert "MultiGeometry>" in mg.to_string()
+ assert "coordinates>" not in mg.to_string()
+
+
+class TestMultiPointLxml(Lxml, TestMultiPointStdLibrary):
+ """Test with lxml."""
+
+
+class TestMultiLineStringLxml(Lxml, TestMultiLineStringStdLibrary):
+ """Test with lxml."""
+
+
+class TestMultiPolygonLxml(Lxml, TestMultiPolygonStdLibrary):
+ """Test with lxml."""
+
+
+class TestGeometryCollectionLxml(Lxml, TestGeometryCollectionStdLibrary):
+ """Test with lxml."""
diff --git a/tests/geometries/point_test.py b/tests/geometries/point_test.py
new file mode 100644
index 00000000..2e60c87e
--- /dev/null
+++ b/tests/geometries/point_test.py
@@ -0,0 +1,110 @@
+# Copyright (C) 2021 - 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
+# 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 geometry classes."""
+from typing import cast
+
+import pygeoif.geometry as geo
+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
+
+
+class TestPoint(StdLibrary):
+ """Test the Point class."""
+
+ def test_init(self) -> None:
+ """Test the init method."""
+ p = geo.Point(1, 2)
+
+ point = Point(geometry=p)
+
+ assert point.geometry == p
+ assert point.altitude_mode is None
+ assert point.extrude is False
+
+ def test_to_string(self) -> None:
+ """Test the to_string method."""
+ p = geo.Point(1, 2)
+
+ point = Point(geometry=p)
+
+ assert "Point" in point.to_string()
+ assert "coordinates>1.000000,2.000000" in point.to_string()
+
+ 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()
+
+ def test_from_string(self) -> None:
+ """Test the from_string method."""
+ point = cast(
+ Point,
+ Point.class_from_string(
+ ''
+ "1.000000,2.000000"
+ ""
+ ),
+ )
+
+ assert point.geometry == geo.Point(1, 2)
+ assert point.altitude_mode is None
+ assert point.extrude is None
+ assert point.tessellate is 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="",
+ )
+
+ def test_from_string_empty_coordinates(self) -> None:
+ with pytest.raises(
+ KMLParseError,
+ match=r"Invalid coordinates in ",
+ ):
+ Point.class_from_string(
+ "",
+ ns="",
+ )
+
+ def test_from_string_invalid_coordinates(self) -> None:
+ with pytest.raises(
+ KMLParseError,
+ match=(
+ r"Invalid coordinates in "
+ "1"
+ ),
+ ):
+ Point.class_from_string(
+ "1",
+ ns="",
+ )
+
+
+class TestPointLxml(Lxml, TestPoint):
+ """Test with lxml."""
diff --git a/tests/geometries/polygon_test.py b/tests/geometries/polygon_test.py
new file mode 100644
index 00000000..5274d233
--- /dev/null
+++ b/tests/geometries/polygon_test.py
@@ -0,0 +1,106 @@
+# Copyright (C) 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
+# 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 geometry classes."""
+from typing import cast
+
+import pygeoif.geometry as geo
+
+from fastkml.geometry import Polygon
+from tests.base import Lxml
+from tests.base import StdLibrary
+
+
+class TestStdLibrary(StdLibrary):
+ """Test with the standard library."""
+
+ def test_exterior_only(self):
+ """Test exterior only."""
+ poly = geo.Polygon([(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)])
+
+ polygon = Polygon(ns="", geometry=poly)
+
+ assert "outerBoundaryIs>" in polygon.to_string()
+ assert "innerBoundaryIs>" not in polygon.to_string()
+ assert "LinearRing>" in polygon.to_string()
+ assert (
+ "0.000000,0.000000 0.000000,1.000000 1.000000,1.000000 "
+ "1.000000,0.000000 0.000000,0.000000"
+ ) in polygon.to_string()
+
+ def test_exterior_interior(self):
+ """Test exterior and interior."""
+ poly = geo.Polygon(
+ [(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)],
+ [[(0.1, 0.1), (0.1, 0.9), (0.9, 0.9), (0.9, 0.1), (0.1, 0.1)]],
+ )
+
+ polygon = Polygon(ns="", geometry=poly)
+
+ assert "outerBoundaryIs>" in polygon.to_string()
+ assert "innerBoundaryIs>" in polygon.to_string()
+ assert "LinearRing>" in polygon.to_string()
+ assert (
+ "0.000000,0.000000 0.000000,1.000000 1.000000,1.000000 "
+ "1.000000,0.000000 0.000000,0.000000"
+ ) in polygon.to_string()
+ assert (
+ "0.100000,0.100000 0.100000,0.900000 0.900000,0.900000 "
+ "0.900000,0.100000 0.100000,0.100000"
+ ) in polygon.to_string()
+
+ def test_from_string_exterior_only(self):
+ """Test exterior only."""
+ doc = """
+
+
+ 0.000000,0.000000 1.000000,0.000000 1.000000,1.000000
+ 0.000000,0.000000
+
+
+ """
+
+ polygon2 = cast(Polygon, Polygon.class_from_string(doc))
+
+ assert polygon2.geometry == geo.Polygon([(0, 0), (1, 0), (1, 1), (0, 0)])
+
+ def test_from_string_exterior_interior(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
+
+
+
+ """
+
+ polygon2 = cast(Polygon, Polygon.class_from_string(doc))
+
+ assert polygon2.geometry == geo.Polygon(
+ [(-1, -1), (2, -1), (2, 2), (-1, -1)],
+ [[(0, 0), (1, 0), (1, 1), (0, 0)]],
+ )
+
+
+class TestLxml(Lxml, TestStdLibrary):
+ """Test with lxml."""
diff --git a/tests/gx_test.py b/tests/gx_test.py
index 66d43167..2269bb60 100644
--- a/tests/gx_test.py
+++ b/tests/gx_test.py
@@ -15,6 +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 MultiTrack
+from fastkml.gx import Track
+from fastkml.gx import TrackItem
from tests.base import Lxml
from tests.base import StdLibrary
@@ -23,5 +33,402 @@ 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 = Track.class_from_string(doc, ns="")
+
+ 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:00+00:00
+ 2020-01-01T00:10:00+00:00
+ 0.000000 0.000000
+ 1.000000 0.000000
+
+
+ 2020-01-01T00:10:00+00:00
+ 2020-01-01T00:20:00+00:00
+ 0.000000 1.000000
+ 1.000000 1.000000
+
+
+ """
+
+ 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 (
+ mt.to_string()
+ == 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,
+ ).to_string()
+ )
+
+
+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 "