diff --git a/.github/workflows/run-all-tests.yml b/.github/workflows/run-all-tests.yml index ead106f4..69cf74d2 100644 --- a/.github/workflows/run-all-tests.yml +++ b/.github/workflows/run-all-tests.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 @@ -20,7 +20,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip wheel - pip install -r test-requirements.txt + pip install -e ".[tests]" - name: Test with pytest run: | pytest tests @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 @@ -40,8 +40,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip wheel - pip install -r test-requirements.txt - pip install lxml + pip install -e ".[tests, lxml]" - name: Test with pytest run: | pytest tests --cov=fastkml --cov=tests --cov-fail-under=88 --cov-report=xml @@ -51,12 +50,13 @@ jobs: with: fail_ci_if_error: true verbose: true + token: ${{ env.CODECOV_TOKEN }} static-tests: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.9'] + python-version: ['3.12'] steps: - uses: actions/checkout@v4 @@ -67,7 +67,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip wheel - pip install -r test-requirements.txt + pip install -e ".[typing, complexity, linting]" - name: Typecheck run: | mypy fastkml @@ -87,7 +87,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - pypy-version: ['pypy-3.7', 'pypy-3.8', 'pypy-3.9', 'pypy-3.10'] + pypy-version: ['pypy-3.8', 'pypy-3.9', 'pypy-3.10'] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.pypy-version }} @@ -96,10 +96,11 @@ jobs: python-version: ${{ matrix.pypy-version }} - name: Install dependencies run: | - python -m pip install --upgrade pip wheel + python -m pip install --upgrade pip wheel setuptools + python -m pip install -e ".[tests]" - name: Test with pytest run: | - python setup.py test + pytest tests publish: if: "github.event_name == 'push' && github.repository == 'cleder/fastkml'" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b6edf3c7..6d9169f0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,11 +20,10 @@ repos: - id: pretty-format-json - id: requirements-txt-fixer - id: trailing-whitespace - - - repo: https://github.com/tox-dev/pyproject-fmt - rev: "1.2.0" + - repo: https://github.com/kieran-ryan/pyprojectsort + rev: v0.3.0 hooks: - - id: pyproject-fmt + - id: pyprojectsort - repo: https://github.com/abravalheri/validate-pyproject rev: v0.15 hooks: @@ -37,30 +36,21 @@ repos: rev: v0.3.1 hooks: - id: absolufy-imports - - repo: https://github.com/hakancelikdev/unimport - rev: 1.0.0 - hooks: - - id: unimport - args: [--remove, --include-star-import, --ignore-init, --gitignore] - repo: https://github.com/asottile/pyupgrade rev: v3.15.0 hooks: - id: pyupgrade args: ["--py3-plus", "--py37-plus"] - repo: https://github.com/psf/black - rev: 23.9.1 + rev: 23.11.0 hooks: - id: black - - repo: https://github.com/PyCQA/flake8 - rev: 6.1.0 - hooks: - - id: flake8 - repo: https://github.com/pycqa/isort rev: 5.12.0 hooks: - id: isort - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: 'v0.0.292' + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 'v0.1.6' hooks: - id: ruff - repo: https://github.com/PyCQA/flake8 @@ -79,6 +69,10 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - id: text-unicode-replacement-char + - repo: https://github.com/rstcheck/rstcheck + rev: "v6.2.0" + hooks: + - id: rstcheck # - repo: https://github.com/mgedmin/check-manifest # rev: "0.49" # hooks: diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..0a3b1864 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,23 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +--- +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# We recommend specifying your dependencies to enable reproducible builds: +# https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +python: + install: + - requirements: docs/requirements.txt +... diff --git a/.sourcery.yaml b/.sourcery.yaml index f300aba4..35fa4dc6 100644 --- a/.sourcery.yaml +++ b/.sourcery.yaml @@ -1,2 +1,2 @@ refactor: - python_version: '3.7' + python_version: '3.8' diff --git a/README.rst b/README.rst index 6df6e595..48bfde94 100644 --- a/README.rst +++ b/README.rst @@ -15,11 +15,6 @@ the functionality that KML clients such as `OpenLayers Geometries are handled as pygeoif_ objects. -.. _pygeoif: http://pypi.python.org/pypi/pygeoif/ -.. _lxml: https://pypi.python.org/pypi/lxml -.. _dateutils: https://pypi.python.org/pypi/dateutils -.. _pip: https://pypi.python.org/pypi/pip - Fastkml is continually tested .. image:: https://github.com/cleder/fastkml/actions/workflows/run-all-tests.yml/badge.svg?branch=main @@ -30,10 +25,18 @@ Fastkml is continually tested :target: http://codecov.io/github/cleder/fastkml?branch=main :alt: codecov.io -.. image:: https://img.shields.io/badge/code+style-black-000000.svg +.. image:: https://img.shields.io/badge/code_style-black-000000.svg :target: https://github.com/psf/black :alt: Black +.. image:: https://img.shields.io/badge/type_checker-mypy-blue + :target: http://mypy-lang.org/ + :alt: Mypy + +.. image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit + :target: https://github.com/pre-commit/pre-commit + :alt: pre-commit + Is Maintained and documented: .. image:: https://img.shields.io/pypi/v/fastkml.svg @@ -44,6 +47,10 @@ Is Maintained and documented: :target: https://pypi.python.org/pypi/fastkml/ :alt: Development Status +.. image:: https://img.shields.io/pypi/l/fastkml + :target: https://www.gnu.org/licenses/lgpl-3.0.en.html + :alt: LGPL - License + .. image:: https://readthedocs.org/projects/fastkml/badge/ :target: https://fastkml.readthedocs.org/ :alt: Documentation @@ -60,6 +67,10 @@ Is Maintained and documented: :target: https://pypi.python.org/pypi/fastkml/ :alt: Supported Python implementations +.. image:: https://img.shields.io/librariesio/release/pypi/fastkml + :target: https://libraries.io/pypi/fastkml + :alt: Libraries.io dependency status for latest release + Documentation ============= @@ -86,15 +97,15 @@ Requirements ------------- * pygeoif_ -* dateutils_ +* lxml_ +* arrow_ Optional --------- * lxml_ -You can install all of the requirements for working with FastKML by using -pip_:: +You can install all of the requirements for working with FastKML by using pip_:: pip install -r requirements.txt @@ -110,3 +121,8 @@ Currently, the only major feature missing for the full Google Earth experience is the `gx extension `_. Please submit a PR with the features you'd like to see implemented. + +.. _pygeoif: http://pypi.python.org/pypi/pygeoif/ +.. _lxml: https://pypi.python.org/pypi/lxml +.. _arrow: https://pypi.python.org/pypi/arrow +.. _pip: https://pypi.python.org/pypi/pip diff --git a/doc-requirements.txt b/doc-requirements.txt deleted file mode 100644 index 101ec4b5..00000000 --- a/doc-requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -# Documentation Requirements -Sphinx -sphinx-rtd-theme diff --git a/docs/conf.py b/docs/conf.py index c87da42f..5d1c1465 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -18,7 +18,7 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath("..")) -import fastkml # noqa: E402 +from fastkml import about # noqa: E402 # -- General configuration ------------------------------------------------ @@ -51,9 +51,9 @@ # built documents. # # The short X.Y version. -version = fastkml.__version__ +version = about.__version__ # The full version, including alpha/beta/rc tags. -release = fastkml.__version__ +release = about.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -205,7 +205,7 @@ "index", "FastKML.tex", "FastKML Documentation", - "Christian Ledermann \\& Ian Lee", + r"Christian Ledermann \& Ian Lee", "manual", ), ] @@ -236,7 +236,7 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ("index", "fastkml", "FastKML Documentation", ["Christian Ledermann & Ian Lee"], 1) + ("index", "fastkml", "FastKML Documentation", ["Christian Ledermann & Ian Lee"], 1), ] # If true, show URL addresses after external links. diff --git a/docs/fastkml.rst b/docs/fastkml.rst new file mode 100644 index 00000000..9d935f0a --- /dev/null +++ b/docs/fastkml.rst @@ -0,0 +1,142 @@ +fastkml package +=============== + +Module contents +--------------- + +.. automodule:: fastkml + :members: + :undoc-members: + :no-index: + + +Submodules +---------- + +fastkml.about module +-------------------- + +.. automodule:: fastkml.about + :members: + :undoc-members: + :show-inheritance: + +fastkml.atom module +------------------- + +.. automodule:: fastkml.atom + :members: + :undoc-members: + :show-inheritance: + +fastkml.base module +------------------- + +.. automodule:: fastkml.base + :members: + :undoc-members: + :show-inheritance: + +fastkml.config module +--------------------- + +.. automodule:: fastkml.config + :members: + :undoc-members: + :show-inheritance: + +fastkml.data module +------------------- + +.. automodule:: fastkml.data + :members: + :undoc-members: + :show-inheritance: + +fastkml.enums module +-------------------- + +.. automodule:: fastkml.enums + :members: + :undoc-members: + :show-inheritance: + +fastkml.exceptions module +------------------------- + +.. automodule:: fastkml.exceptions + :members: + :undoc-members: + :show-inheritance: + +fastkml.geometry module +----------------------- + +.. automodule:: fastkml.geometry + :members: + :undoc-members: + :show-inheritance: + +fastkml.gx module +----------------- + +.. automodule:: fastkml.gx + :members: + :undoc-members: + :show-inheritance: + +fastkml.helpers module +---------------------- + +.. automodule:: fastkml.helpers + :members: + :undoc-members: + :show-inheritance: + +fastkml.kml module +------------------ + +.. automodule:: fastkml.kml + :members: + :undoc-members: + :show-inheritance: + +fastkml.mixins module +--------------------- + +.. automodule:: fastkml.mixins + :members: + :undoc-members: + :show-inheritance: + +fastkml.styles module +--------------------- + +.. automodule:: fastkml.styles + :members: + :undoc-members: + :show-inheritance: + +fastkml.times module +-------------------- + +.. automodule:: fastkml.times + :members: + :undoc-members: + :show-inheritance: + +fastkml.types module +-------------------- + +.. automodule:: fastkml.types + :members: + :undoc-members: + :show-inheritance: + +fastkml.views module +-------------------- + +.. automodule:: fastkml.views + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/index.rst b/docs/index.rst index 45b422b6..f9c92016 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,46 +1,7 @@ Welcome to FastKML's documentation! =================================== -Fastkml is continually tested with *Travis CI*: - -.. image:: https://api.travis-ci.org/cleder/fastkml.png - :target: https://travis-ci.org/cleder/fastkml - -.. image:: https://coveralls.io/repos/cleder/fastkml/badge.png?branch=master - :target: https://coveralls.io/r/cleder/fastkml?branch=master - - -Is Maintained and documented: - -.. image:: https://pypip.in/v/fastkml/badge.png - :target: https://pypi.python.org/pypi/fastkml - :alt: Latest PyPI version - -.. image:: https://pypip.in/status/fastkml/badge.svg - :target: https://pypi.python.org/pypi/fastkml/ - :alt: Development Status - -.. image:: https://readthedocs.org/projects/fastkml/badge/ - :target: https://fastkml.readthedocs.org/ - -.. image:: https://www.openhub.net/p/fastkml/widgets/project_thin_badge.gif - :target: https://www.openhub.net/p/fastkml - -Follows best practises: - -.. image:: https://landscape.io/github/cleder/fastkml/master/landscape.svg?style=plastic - :target: https://landscape.io/github/cleder/fastkml/master - :alt: Code Health - -.. image:: https://pypip.in/py_versions/fastkml/badge.svg - :target: https://pypi.python.org/pypi/fastkml/ - :alt: Supported Python versions - -.. image:: https://pypip.in/implementation/fastkml/badge.svg - :target: https://pypi.python.org/pypi/fastkml/ - :alt: Supported Python implementations - -fastkml is a library to read, write and manipulate KML files. It aims to keep +``fastkml`` is a library to read, write and manipulate KML files. It aims to keep it simple and fast (using lxml_ if available). "Fast" refers to the time you spend to write and read KML files, as well as the time you spend to get acquainted with the library or to create KML objects. It provides a subset of KML @@ -74,6 +35,7 @@ requirements, namely: installing usage_guide reference_guide + modules contributing .. _lxml: https://pypi.python.org/pypi/lxml diff --git a/docs/modules.rst b/docs/modules.rst new file mode 100644 index 00000000..8af3d880 --- /dev/null +++ b/docs/modules.rst @@ -0,0 +1,7 @@ +fastkml +======= + +.. toctree:: + :maxdepth: 4 + + fastkml diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 00000000..57e58a5f --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,7 @@ +arrow +pygeoif>=1.1 +# Documentation Requirements +Sphinx +sphinx-autodoc-typehints +sphinx-rtd-theme +typing-extensions>4 diff --git a/examples/CreateKml.py b/examples/CreateKml.py index 81595dfe..6906825b 100644 --- a/examples/CreateKml.py +++ b/examples/CreateKml.py @@ -5,7 +5,7 @@ # Create the root KML object k = kml.KML() -ns = "{http://www.opengis.net/kml/2.2}" # noqa: FS003 +ns = "{http://www.opengis.net/kml/2.2}" # Create a KML Document and add it to the KML root object d = kml.Document(ns, "docid", "doc name", "doc description") diff --git a/examples/UsageExamples.py b/examples/UsageExamples.py index 552a2d7f..bf4a44f2 100644 --- a/examples/UsageExamples.py +++ b/examples/UsageExamples.py @@ -4,7 +4,7 @@ def print_child_features(element): - """Prints the name of every child node of the given element, recursively""" + """Prints the name of every child node of the given element, recursively.""" if not getattr(element, "features", None): return for feature in element.features(): @@ -17,7 +17,7 @@ def print_child_features(element): k = kml.KML() - with open(fname) as kml_file: + with open(fname, encoding="utf-8") as kml_file: k.from_string(kml_file.read().encode("utf-8")) print_child_features(k) diff --git a/fastkml/__init__.py b/fastkml/__init__.py index 4f603f57..21365bbd 100644 --- a/fastkml/__init__.py +++ b/fastkml/__init__.py @@ -15,18 +15,16 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """ -Fastkml is a library to read, write and manipulate kml files. It aims to keep -it simple and fast (using lxml if available). Fast refers to the time you spend -to write and read KML files as well as the time you spend to get aquainted to -the library or to create KML objects. It provides a subset of KML and is aimed -at documents that can be read from multiple clients such as openlayers and -google maps rather than to give you all functionality that KML on google earth -provides. -""" - -from pkg_resources import DistributionNotFound -from pkg_resources import get_distribution +Fastkml is a library to read, write and manipulate kml files. +It aims to keep it simple and fast (using lxml if available). +Fast refers to the time you spend to write and read KML files as well as the time +you spend to get acquainted to the library or to create KML objects. +It provides a subset of KML and is aimed at documents that can be read from +multiple clients such as openlayers and google maps rather than to give you all +functionality that KML on google earth provides. +""" +from fastkml.about import __version__ # noqa: F401 from fastkml.atom import Author from fastkml.atom import Contributor from fastkml.atom import Link @@ -51,11 +49,6 @@ from fastkml.views import Camera from fastkml.views import LookAt -try: - __version__ = get_distribution("fastkml").version -except DistributionNotFound: - __version__ = "dev" - __all__ = [ "KML", "Document", diff --git a/fastkml/about.py b/fastkml/about.py new file mode 100644 index 00000000..5496fedc --- /dev/null +++ b/fastkml/about.py @@ -0,0 +1,6 @@ +""" +About fastkml. + +The only purpose of this module is to provide a version number for the package. +""" +__version__ = "1.0.a7" diff --git a/fastkml/atom.py b/fastkml/atom.py index a07be3b1..d497d271 100644 --- a/fastkml/atom.py +++ b/fastkml/atom.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012 - 2021 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 @@ -33,19 +33,15 @@ import logging import re +from typing import Any +from typing import Dict from typing import Optional -from typing import Tuple +from fastkml import config 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 -from fastkml.helpers import o_to_attr -from fastkml.helpers import o_to_subelement_text from fastkml.types import Element -from fastkml.types import KmlObjectMap logger = logging.getLogger(__name__) regex = r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)" @@ -59,9 +55,8 @@ def check_email(email: str) -> bool: class Link(_XMLObject): """ - Identifies a related Web page. The type of relation is defined by - the rel attribute. A feed is limited to one alternate per type and - hreflang. + Identifies a related Web page. The rel attribute defines the type of relation. + A feed is limited to one alternate per type and hreflang. is patterned after html's link element. It has one required attribute, href, and five optional attributes: rel, type, hreflang, title, and length. @@ -69,61 +64,10 @@ class Link(_XMLObject): __name__ = "link" - kml_object_mapping: Tuple[KmlObjectMap, ...] = ( - { - "kml_attr": "href", - "obj_attr": "href", - "from_kml": o_from_attr, - "to_kml": o_to_attr, - "required": True, - "validator": None, - }, - { - "kml_attr": "rel", - "obj_attr": "rel", - "from_kml": o_from_attr, - "to_kml": o_to_attr, - "required": False, - "validator": None, - }, - { - "kml_attr": "type", - "obj_attr": "type", - "from_kml": o_from_attr, - "to_kml": o_to_attr, - "required": False, - "validator": None, - }, - { - "kml_attr": "hreflang", - "obj_attr": "hreflang", - "from_kml": o_from_attr, - "to_kml": o_to_attr, - "required": False, - "validator": None, - }, - { - "kml_attr": "title", - "obj_attr": "title", - "from_kml": o_from_attr, - "to_kml": o_to_attr, - "required": False, - "validator": None, - }, - { - "kml_attr": "length", - "obj_attr": "length", - "from_kml": o_int_from_attr, - "to_kml": o_to_attr, - "required": False, - "validator": None, - }, - ) - - href = None + href: Optional[str] # href is the URI of the referenced resource - rel = None + rel: Optional[str] # rel contains a single link relationship type. # It can be a full URI, or one of the following predefined values # (default=alternate): @@ -135,21 +79,22 @@ class Link(_XMLObject): # self: the feed itself. # via: the source of the information provided in the entry. - type = None + type: Optional[str] # indicates the media type of the resource - hreflang = None + hreflang: Optional[str] # indicates the language of the referenced resource - title = None + title: Optional[str] # human readable information about the link - length = None + length: Optional[int] # the length of the resource, in bytes def __init__( self, ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, href: Optional[str] = None, rel: Optional[str] = None, type: Optional[str] = None, @@ -157,7 +102,7 @@ def __init__( title: Optional[str] = None, length: Optional[int] = None, ) -> None: - self.ns: str = NS if ns is None else ns + super().__init__(ns=ns, name_spaces=name_spaces) self.href = href self.rel = rel self.type = type @@ -165,15 +110,64 @@ def __init__( self.title = title self.length = length - def from_element(self, element: Element) -> None: - super().from_element(element) + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(" + f"ns={self.ns!r}, " + f"href={self.href!r}, " + f"rel={self.rel!r}, " + f"type={self.type!r}, " + f"hreflang={self.hreflang!r}, " + f"title={self.title!r}, " + f"length={self.length!r}, " + ")" + ) def etree_element( self, precision: Optional[int] = None, verbosity: Verbosity = Verbosity.normal, ) -> Element: - return super().etree_element(precision=precision, verbosity=verbosity) + element = super().etree_element(precision=precision, verbosity=verbosity) + if self.href: + element.set("href", self.href) + else: + logger.warning("required attribute href missing") + if self.rel: + element.set("rel", self.rel) + if self.type: + element.set("type", self.type) + if self.hreflang: + element.set("hreflang", self.hreflang) + if self.title: + element.set("title", self.title) + if self.length: + element.set("length", str(self.length)) + return element + + @classmethod + def _get_kwargs( + cls, + *, + ns: str, + name_spaces: Optional[Dict[str, str]] = None, + element: Element, + strict: bool, + ) -> Dict[str, Any]: + kwargs = super()._get_kwargs( + ns=ns, + name_spaces=name_spaces, + element=element, + strict=strict, + ) + kwargs["href"] = element.get("href") + kwargs["rel"] = element.get("rel") + kwargs["type"] = element.get("type") + kwargs["hreflang"] = element.get("hreflang") + kwargs["title"] = element.get("title") + length = element.get("length") + kwargs["length"] = int(length) if length else None + return kwargs class _Person(_XMLObject): @@ -184,63 +178,93 @@ class _Person(_XMLObject): """ __name__ = "" - kml_object_mapping: Tuple[KmlObjectMap, ...] = ( - { - "kml_attr": "name", - "obj_attr": "name", - "from_kml": o_from_subelement_text, - "to_kml": o_to_subelement_text, - "required": True, - "validator": None, - }, - { - "kml_attr": "uri", - "obj_attr": "uri", - "from_kml": o_from_subelement_text, - "to_kml": o_to_subelement_text, - "required": False, - "validator": None, - }, - { - "kml_attr": "email", - "obj_attr": "email", - "from_kml": o_from_subelement_text, - "to_kml": o_to_subelement_text, - "required": False, - "validator": check_email, - }, - ) - - name: Optional[str] = None + + name: Optional[str] # conveys a human-readable name for the person. - uri: Optional[str] = None + uri: Optional[str] # contains a home page for the person. - email: Optional[str] = None + email: Optional[str] # contains an email address for the person. def __init__( self, ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, name: Optional[str] = None, uri: Optional[str] = None, email: Optional[str] = None, ) -> None: - self.ns: str = NS if ns is None else ns + super().__init__(ns=ns, name_spaces=name_spaces) self.name = name self.uri = uri self.email = email + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(" + f"ns={self.ns!r}, " + f"name={self.name!r}, " + f"uri={self.uri!r}, " + f"email={self.email!r}, " + ")" + ) + 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) + self.__name__ = self.__class__.__name__.lower() + element = super().etree_element(precision=precision, verbosity=verbosity) + if self.name: + name = config.etree.SubElement( # type: ignore[attr-defined] + element, + f"{self.ns}name", + ) + name.text = self.name + else: + logger.warning("No Name for person defined") + if self.uri: + uri = config.etree.SubElement( # type: ignore[attr-defined] + element, + f"{self.ns}uri", + ) + uri.text = self.uri + if self.email and check_email(self.email): + email = config.etree.SubElement( # type: ignore[attr-defined] + element, + f"{self.ns}email", + ) + email.text = self.email + return element + + @classmethod + def _get_kwargs( + cls, + *, + ns: str, + name_spaces: Optional[Dict[str, str]] = None, + element: Element, + strict: bool, + ) -> Dict[str, Any]: + kwargs = super()._get_kwargs( + ns=ns, + name_spaces=name_spaces, + element=element, + strict=strict, + ) + name = element.find(f"{ns}name") + if name is not None: + kwargs["name"] = name.text + uri = element.find(f"{ns}uri") + if uri is not None: + kwargs["uri"] = uri.text + email = element.find(f"{ns}email") + if email is not None: + kwargs["email"] = email.text + return kwargs class Author(_Person): @@ -263,4 +287,4 @@ class Contributor(_Person): __name__ = "contributor" -__all__ = ["Author", "Contributor", "Link"] +__all__ = ["Author", "Contributor", "Link", "NS"] diff --git a/fastkml/base.py b/fastkml/base.py index 048fbf91..49e519b9 100644 --- a/fastkml/base.py +++ b/fastkml/base.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012 - 2020 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 @@ -14,20 +14,16 @@ # along with this library; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -"""Abstract base classes""" +"""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 -from fastkml.types import KmlObjectMap logger = logging.getLogger(__name__) @@ -35,24 +31,20 @@ class _XMLObject: """XML Baseclass.""" - _namespaces: Tuple[str, ...] = ("",) + _default_ns: str = "" _node_name: str = "" __name__ = "" - kml_object_mapping: Tuple[KmlObjectMap, ...] = () + name_spaces: Dict[str, str] - def __init__(self, ns: Optional[str] = None) -> None: + def __init__( + self, + ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, + ) -> None: """Initialize the XML Object.""" - self.ns: str = self._namespaces[0] if ns is None else ns - - 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 - ) + self.ns: str = self._default_ns if ns is None else ns + name_spaces = name_spaces or {} + self.name_spaces = {**config.NAME_SPACES, **name_spaces} def __repr__(self) -> str: return f"{self.__class__.__name__}(ns={self.ns})" @@ -68,14 +60,13 @@ def etree_element( """Return the KML Object as an Element.""" if self.__name__: element: Element = config.etree.Element( # type: ignore[attr-defined] - f"{self.ns}{self.__name__}" + f"{self.ns}{self.__name__}", ) else: + msg = "Call of abstract base class, subclasses implement this!" raise NotImplementedError( - "Call of abstract base class, subclasses implement this!" + msg, ) - for mapping in self.kml_object_mapping: - mapping["to_kml"](self, element, **mapping) return element def from_element(self, element: Element) -> None: @@ -86,9 +77,8 @@ def from_element(self, element: Element) -> None: 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) + msg = "Call of abstract base class, subclasses implement this!" + raise TypeError(msg) def from_string(self, xml_string: str) -> None: """ @@ -98,7 +88,7 @@ def from_string(self, xml_string: str) -> None: making it a classmethod. """ self.from_element( - cast(Element, config.etree.XML(xml_string)) # type: ignore[attr-defined] + cast(Element, config.etree.XML(xml_string)), # type: ignore[attr-defined] ) def to_string( @@ -119,30 +109,38 @@ def to_string( ), encoding="UTF-8", pretty_print=prettyprint, - ).decode("UTF-8"), + ).decode( + "UTF-8", + ), ) except TypeError: return cast( str, config.etree.tostring( # type: ignore[attr-defined] - self.etree_element(), encoding="UTF-8" - ).decode("UTF-8"), + self.etree_element(), + encoding="UTF-8", + ).decode( + "UTF-8", + ), ) @classmethod def _get_ns(cls, ns: Optional[str]) -> str: - return cls._namespaces[0] if ns is None else ns + return cls._default_ns if ns is None else ns @classmethod def _get_kwargs( cls, *, ns: str, + name_spaces: Optional[Dict[str, str]] = None, element: Element, strict: bool, ) -> Dict[str, Any]: """Returns a dictionary of kwargs for the class constructor.""" - kwargs: Dict[str, Any] = {} + name_spaces = name_spaces or {} + name_spaces = {**config.NAME_SPACES, **name_spaces} + kwargs: Dict[str, Any] = {"ns": ns, "name_spaces": name_spaces} return kwargs @classmethod @@ -150,13 +148,18 @@ def class_from_element( cls, *, ns: str, + name_spaces: Optional[Dict[str, str]] = None, 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( + kwargs = cls._get_kwargs( ns=ns, + name_spaces=name_spaces, + element=element, + strict=strict, + ) + return cls( **kwargs, ) @@ -166,19 +169,24 @@ def class_from_string( string: str, *, ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, strict: bool = True, ) -> "_XMLObject": - """Creates a geometry object from a string. + """ + 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, + name_spaces=name_spaces, strict=strict, element=cast( Element, @@ -199,38 +207,20 @@ class _BaseObject(_XMLObject): mechanism is to be used. """ - _namespace = config.KMLNS - _namespaces: Tuple[str, ...] = (config.KMLNS,) + _default_ns = config.KMLNS id = None target_id = None - kml_object_mapping: Tuple[KmlObjectMap, ...] = ( - { - "kml_attr": "id", - "obj_attr": "id", - "from_kml": o_from_attr, - "to_kml": o_to_attr, - "required": False, - "validator": None, - }, - { - "kml_attr": "targetId", - "obj_attr": "target_id", - "from_kml": o_from_attr, - "to_kml": o_to_attr, - "required": False, - "validator": None, - }, - ) def __init__( self, ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, id: Optional[str] = None, target_id: Optional[str] = None, ) -> None: """Initialize the KML Object.""" - super().__init__(ns) + super().__init__(ns=ns, name_spaces=name_spaces) self.id = id self.target_id = target_id @@ -245,6 +235,7 @@ def __eq__(self, other: object) -> bool: def __repr__(self) -> str: return ( f"{self.__class__.__name__}(ns={self.ns!r}, " + f"name_spaces={self.name_spaces!r}, " f"(id={self.id!r}, target_id={self.target_id!r})" ) @@ -254,11 +245,20 @@ def etree_element( verbosity: Verbosity = Verbosity.normal, ) -> Element: """Return the KML Object as an Element.""" - return super().etree_element(precision=precision, verbosity=verbosity) + element = super().etree_element(precision=precision, verbosity=verbosity) + if self.id: + element.set("id", self.id) + if self.target_id: + element.set("targetId", self.target_id) + return element def from_element(self, element: Element) -> None: """Load the KML Object from an Element.""" super().from_element(element) + if element.get("id"): + self.id = element.get("id") + if element.get("targetId"): + self.target_id = element.get("targetId") @classmethod def _get_id(cls, element: Element, strict: bool) -> str: @@ -273,11 +273,21 @@ def _get_kwargs( cls, *, ns: str, + name_spaces: Optional[Dict[str, str]] = None, 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), - } + kwargs = super()._get_kwargs( + ns=ns, + name_spaces=name_spaces, + element=element, + strict=strict, + ) + kwargs.update( + { + "id": cls._get_id(element=element, strict=strict), + "target_id": cls._get_target_id(element=element, strict=strict), + }, + ) + return kwargs diff --git a/fastkml/config.py b/fastkml/config.py index 699ce5a3..6dd4a700 100644 --- a/fastkml/config.py +++ b/fastkml/config.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012 - 2021 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 @@ -14,7 +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 -"""Frequently used constants and configuration options""" +"""Frequently used constants and configuration options.""" import logging import warnings from types import ModuleType @@ -22,7 +22,6 @@ __all__ = [ "ATOMNS", "DEFAULT_NAME_SPACES", - "FORCE3D", "GXNS", "KMLNS", "register_namespaces", @@ -34,8 +33,8 @@ from lxml import etree except ImportError: # pragma: no cover - warnings.warn("Package `lxml` missing. Pretty print will be disabled") - import xml.etree.ElementTree as etree # type: ignore[no-redef] # noqa: N813 + warnings.warn("Package `lxml` missing. Pretty print will be disabled") # noqa: B028 + import xml.etree.ElementTree as etree # noqa: N813 logger = logging.getLogger(__name__) @@ -43,13 +42,13 @@ def set_etree_implementation(implementation: ModuleType) -> None: """Set the etree implementation to use.""" - global etree + global etree # noqa: PLW0603 etree = implementation -KMLNS = "{http://www.opengis.net/kml/2.2}" # noqa: FS003 -ATOMNS = "{http://www.w3.org/2005/Atom}" # noqa: FS003 -GXNS = "{http://www.google.com/kml/ext/2.2}" # noqa: FS003 +KMLNS = "{http://www.opengis.net/kml/2.2}" +ATOMNS = "{http://www.w3.org/2005/Atom}" +GXNS = "{http://www.google.com/kml/ext/2.2}" NAME_SPACES = { "kml": KMLNS, @@ -76,5 +75,3 @@ def set_default_namespaces() -> None: set_default_namespaces() - -FORCE3D = False diff --git a/fastkml/data.py b/fastkml/data.py index 72a5bb7a..b54ca159 100644 --- a/fastkml/data.py +++ b/fastkml/data.py @@ -1,4 +1,4 @@ -# Copyright (C) 2022 Christian Ledermann +# Copyright (C) 2022 - 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,48 +13,78 @@ # 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 -"""Add Custom Data""" +""" +Add Custom Data. + +https://developers.google.com/kml/documentation/extendeddata#example +""" import logging +from dataclasses import dataclass +from typing import Any from typing import Dict +from typing import Iterable from typing import List from typing import Optional from typing import Tuple from typing import Union -from typing import overload - -from typing_extensions import TypedDict -import fastkml.config as config +from fastkml import config from fastkml.base import _BaseObject from fastkml.base import _XMLObject +from fastkml.enums import DataType from fastkml.enums import Verbosity +from fastkml.exceptions import KMLSchemaError from fastkml.types import Element -logger = logging.getLogger(__name__) +__all__ = [ + "Data", + "ExtendedData", + "Schema", + "SchemaData", + "SimpleField", +] +logger = logging.getLogger(__name__) -class SimpleField(TypedDict): - name: str - type: str - displayName: Optional[str] # noqa: N815 +@dataclass(frozen=True) +class SimpleField: + """ + A SimpleField always has both name and type attributes. + + The declaration of the custom field, must specify both the type + and the name of this field. + If either the type or the name is omitted, the field is ignored. + + The type can be one of the following: + - string + - int + - uint + - short + - ushort + - float + - double + - bool + + The displayName, if any, to be used when the field name is displayed to + the Google Earth user. Use the [CDATA] element to escape standard + HTML markup. + """ -SimpleFields = List[Dict[str, str]] -SimpleFieldsListInput = List[Union[Dict[str, str], List[Dict[str, str]]]] -SimpleFieldsTupleInput = Tuple[Union[Dict[str, str], Tuple[Dict[str, str]]]] -SimpleFieldsDictInput = Dict[str, str] -SimpleFieldsInput = Optional[ - Union[SimpleFieldsListInput, SimpleFieldsTupleInput, SimpleFieldsDictInput] -] -SimpleFieldsOutput = Tuple[SimpleField, ...] + name: str + type: DataType + display_name: Optional[str] = None class Schema(_BaseObject): """ - Specifies a custom KML schema that is used to add custom data to - KML Features. + Specifies a custom KML schema that is used to add custom data to KML Features. + The "id" attribute is required and must be unique within the KML file. is always a child of . + + https://developers.google.com/kml/documentation/extendeddata#declare-the-schema-element + """ __name__ = "Schema" @@ -62,115 +92,41 @@ class Schema(_BaseObject): def __init__( self, ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, id: Optional[str] = None, target_id: Optional[str] = None, name: Optional[str] = None, - fields: SimpleFieldsInput = None, + fields: Optional[Iterable[SimpleField]] = None, ) -> None: if id is None: - raise ValueError("Id is required for schema") - super().__init__(ns=ns, id=id, target_id=target_id) + msg = "Id is required for schema" + raise KMLSchemaError(msg) + super().__init__(ns=ns, name_spaces=name_spaces, id=id, target_id=target_id) self.name = name - self._simple_fields: SimpleFields = [] - self.simple_fields = fields # type: ignore[assignment] + self._simple_fields = list(fields) if fields else [] - @property - def simple_fields(self) -> SimpleFieldsOutput: - return tuple( - SimpleField( - type=simple_field.get("type", ""), - name=simple_field.get("name", ""), - displayName=simple_field.get("displayName") or None, - ) - for simple_field in self._simple_fields - if simple_field.get("type") and simple_field.get("name") + 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"name={self.name}, " + f"fields={self.simple_fields!r}" + ")" ) - @simple_fields.setter - @overload - def simple_fields(self, fields: SimpleFieldsListInput) -> None: - ... - - @simple_fields.setter - @overload - def simple_fields(self, fields: SimpleFieldsTupleInput) -> None: - ... - - @simple_fields.setter - @overload - def simple_fields(self, fields: SimpleFieldsDictInput) -> None: - ... + @property + def simple_fields(self) -> Tuple[SimpleField, ...]: + return tuple(self._simple_fields) @simple_fields.setter - def simple_fields(self, fields: SimpleFieldsInput) -> None: - if isinstance(fields, dict): - self.append(**fields) - elif isinstance(fields, (list, tuple)): - for field in fields: - if isinstance(field, (list, tuple)): - self.append(*field) - elif isinstance(field, dict): - self.append(**field) - elif fields is None: - self._simple_fields = [] - else: - raise ValueError("Fields must be of type list, tuple or dict") - - def append(self, type: str, name: str, display_name: Optional[str] = None) -> None: - """ - append a field. - The declaration of the custom field, must specify both the type - and the name of this field. - If either the type or the name is omitted, the field is ignored. - - The type can be one of the following: - string - int - uint - short - ushort - float - double - bool - - - The name, if any, to be used when the field name is displayed to - the Google Earth user. Use the [CDATA] element to escape standard - HTML markup. - """ - allowed_types = [ - "string", - "int", - "uint", - "short", - "ushort", - "float", - "double", - "bool", - ] - if type not in allowed_types: - logger.warning( - "%s has the type %s which is invalid. " - "The type must be one of " - "'string', 'int', 'uint', 'short', " - "'ushort', 'float', 'double', 'bool'", - name, - type, - ) - self._simple_fields.append( - {"type": type, "name": name, "displayName": display_name or ""} - ) + def simple_fields(self, fields: Iterable[SimpleField]) -> None: + self._simple_fields = list(fields) - def from_element(self, element: Element) -> None: - super().from_element(element) - self.name = element.get("name") - simple_fields = element.findall(f"{self.ns}SimpleField") - for simple_field in simple_fields: - sfname = simple_field.get("name") - sftype = simple_field.get("type") - display_name = simple_field.find(f"{self.ns}displayName") - sfdisplay_name = display_name.text if display_name is not None else None - self.append(sftype, sfname, sfdisplay_name) + def append(self, field: SimpleField) -> None: + """Append a field.""" + self._simple_fields.append(field) def etree_element( self, @@ -182,17 +138,64 @@ def etree_element( element.set("name", self.name) for simple_field in self.simple_fields: sf = config.etree.SubElement( # type: ignore[attr-defined] - element, f"{self.ns}SimpleField" + element, + f"{self.ns}SimpleField", ) - sf.set("type", simple_field["type"]) - sf.set("name", simple_field["name"]) - if simple_field.get("displayName"): + sf.set("type", simple_field.type.value) + sf.set("name", simple_field.name) + if simple_field.display_name: dn = config.etree.SubElement( # type: ignore[attr-defined] - sf, f"{self.ns}displayName" + sf, + f"{self.ns}displayName", ) - dn.text = simple_field["displayName"] + dn.text = simple_field.display_name return element + @classmethod + def _get_fields_kwargs_from_element( + cls, + *, + ns: str, + element: Element, + strict: bool, + ) -> List[SimpleField]: + def get_display_name(field: Element) -> Optional[str]: + display_name = field.find(f"{ns}displayName") + return display_name.text if display_name is not None else None + + return [ + SimpleField( + name=field.get("name"), + type=DataType(field.get("type")), + display_name=get_display_name(field), + ) + for field in element.findall(f"{ns}SimpleField") + if field is not None + ] + + @classmethod + def _get_kwargs( + cls, + *, + ns: str, + name_spaces: Optional[Dict[str, str]] = None, + element: Element, + strict: bool, + ) -> Dict[str, Any]: + kwargs = super()._get_kwargs( + ns=ns, + name_spaces=name_spaces, + element=element, + strict=strict, + ) + kwargs["name"] = element.get("name") + kwargs["fields"] = cls._get_fields_kwargs_from_element( + ns=ns, + element=element, + strict=strict, + ) + return kwargs + class Data(_XMLObject): """Represents an untyped name/value pair with optional display name.""" @@ -202,11 +205,12 @@ class Data(_XMLObject): def __init__( self, ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, name: Optional[str] = None, value: Optional[str] = None, display_name: Optional[str] = None, ) -> None: - super().__init__(ns) + super().__init__(ns=ns, name_spaces=name_spaces) self.name = name self.value = value @@ -228,82 +232,47 @@ def etree_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" + element, + f"{self.ns}value", ) value.text = self.value if self.display_name: display_name = config.etree.SubElement( # type: ignore[attr-defined] - element, f"{self.ns}displayName" + element, + f"{self.ns}displayName", ) display_name.text = self.display_name return element - def from_element(self, element: Element) -> None: - super().from_element(element) - self.name = element.get("name") - tmp_value = element.find(f"{self.ns}value") - if tmp_value is not None: - self.value = tmp_value.text - display_name = element.find(f"{self.ns}displayName") + @classmethod + def _get_kwargs( + cls, + *, + ns: str, + name_spaces: Optional[Dict[str, str]] = None, + element: Element, + strict: bool, + ) -> Dict[str, Any]: + kwargs = super()._get_kwargs( + ns=ns, + name_spaces=name_spaces, + element=element, + strict=strict, + ) + kwargs["name"] = element.get("name") + value = element.find(f"{ns}value") + if value is not None: + kwargs["value"] = value.text + display_name = element.find(f"{ns}displayName") if display_name is not None: - self.display_name = display_name.text - - -class ExtendedData(_XMLObject): - """Represents a list of untyped name/value pairs. See docs: - - -> 'Adding Untyped Name/Value Pairs' - https://developers.google.com/kml/documentation/extendeddata - - """ + kwargs["display_name"] = display_name.text + return kwargs - __name__ = "ExtendedData" - - def __init__( - self, - ns: Optional[str] = None, - elements: Optional[List[Union[Data, "SchemaData"]]] = None, - ) -> None: - super().__init__(ns) - self.elements = elements or [] - - 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 - def from_element(self, element: Element) -> None: - super().from_element(element) - self.elements = [] - untyped_data = element.findall(f"{self.ns}Data") - for ud in untyped_data: - el_data = Data(self.ns) - el_data.from_element(ud) - self.elements.append(el_data) - typed_data = element.findall(f"{self.ns}SchemaData") - for sd in typed_data: - el_schema_data = SchemaData(self.ns, "dummy") - el_schema_data.from_element(sd) - self.elements.append(el_schema_data) - - -SchemaDataType = List[Dict[str, Union[int, str]]] -SchemaDataListInput = List[Union[Dict[str, str], SchemaDataType]] -SchemaDataTupleInput = Tuple[Union[Dict[str, str], Tuple[Dict[str, Union[int, str]]]]] -SchemaDataDictInput = Dict[str, Union[int, str]] -SchemaDataInput = Optional[ - Union[ - SchemaDataListInput, - SchemaDataTupleInput, - SchemaDataDictInput, - ] -] -SchemaDataOutput = Tuple[Dict[str, Union[int, str]], ...] +@dataclass(frozen=True) +class SimpleData: + name: str + value: Union[int, str, float, bool] class SchemaData(_XMLObject): @@ -324,54 +293,35 @@ class SchemaData(_XMLObject): def __init__( self, ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, schema_url: Optional[str] = None, - data: Optional[List[Dict[str, str]]] = None, + data: Optional[Iterable[SimpleData]] = None, ) -> None: - super().__init__(ns) + super().__init__(ns=ns, name_spaces=name_spaces) if (not isinstance(schema_url, str)) or (not schema_url): - raise ValueError("required parameter schema_url missing") + msg = "required parameter schema_url missing" + raise ValueError(msg) self.schema_url = schema_url - self._data: SchemaDataType = [] - self.data = data # type: ignore[assignment] + self._data = list(data) if data else [] + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(" + f"ns='{self.ns}'," + f"schema_url='{self.schema_url}', " + f"data='{self.data}')" + ) @property - def data(self) -> SchemaDataOutput: + def data(self) -> Tuple[SimpleData, ...]: return tuple(self._data) @data.setter - @overload - def data(self, data: SchemaDataListInput) -> None: - ... + def data(self, data: Iterable[SimpleData]) -> None: + self._data = list(data) - @data.setter - @overload - def data(self, data: SchemaDataTupleInput) -> None: - ... - - @data.setter - @overload - def data(self, data: SchemaDataDictInput) -> None: - ... - - @data.setter - def data(self, data: SchemaDataInput) -> None: - if isinstance(data, (tuple, list)): - self._data = [] - for d in data: - if isinstance(d, (tuple, list)): - self.append_data(*d) - elif isinstance(d, dict): - self.append_data(**d) - elif data is None: - self._data = [] - else: - raise TypeError("data must be of type tuple or list") - - def append_data(self, name: str, value: Union[int, str]) -> None: - if isinstance(name, str) and name: - self._data.append({"name": name, "value": value}) - else: - raise TypeError("name must be a nonempty string") + def append_data(self, data: SimpleData) -> None: + self._data.append(data) def etree_element( self, @@ -382,16 +332,101 @@ def etree_element( element.set("schemaUrl", self.schema_url) for data in self.data: sd = config.etree.SubElement( # type: ignore[attr-defined] - element, f"{self.ns}SimpleData" + element, + f"{self.ns}SimpleData", ) - sd.set("name", data["name"]) - sd.text = data["value"] + sd.set("name", data.name) + sd.text = data.value + return element + + @classmethod + def _get_kwargs( + cls, + *, + ns: str, + name_spaces: Optional[Dict[str, str]] = None, + element: Element, + strict: bool, + ) -> Dict[str, Any]: + kwargs = super()._get_kwargs( + ns=ns, + name_spaces=name_spaces, + element=element, + strict=strict, + ) + kwargs["schema_url"] = element.get("schemaUrl") + kwargs["data"] = [ + SimpleData(name=sd.get("name"), value=sd.text) + for sd in element.findall(f"{ns}SimpleData") + if sd is not None + ] + return kwargs + + +class ExtendedData(_XMLObject): + """ + Represents a list of untyped name/value pairs. See docs: + + -> 'Adding Untyped Name/Value Pairs' + https://developers.google.com/kml/documentation/extendeddata + + """ + + __name__ = "ExtendedData" + + def __init__( + self, + ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, + elements: Optional[Iterable[Union[Data, SchemaData]]] = None, + ) -> None: + super().__init__(ns=ns, name_spaces=name_spaces) + self.elements = elements or [] + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(" + f"ns='{self.ns}'," + f"elements='{self.elements}')" + ) + + 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 - def from_element(self, element: Element) -> None: - super().from_element(element) - self.data = [] # type: ignore[assignment] - self.schema_url = element.get("schemaUrl") - simple_data = element.findall(f"{self.ns}SimpleData") - for sd in simple_data: - self.append_data(sd.get("name"), sd.text) + @classmethod + def _get_kwargs( + cls, + *, + ns: str, + name_spaces: Optional[Dict[str, str]] = None, + element: Element, + strict: bool, + ) -> Dict[str, Any]: + kwargs = super()._get_kwargs( + ns=ns, + name_spaces=name_spaces, + element=element, + strict=strict, + ) + elements = [] + untyped_data = element.findall(f"{ns}Data") + for ud in untyped_data: + el_data = Data.class_from_element(ns=ns, element=ud, strict=strict) + elements.append(el_data) + typed_data = element.findall(f"{ns}SchemaData") + for sd in typed_data: + el_schema_data = SchemaData.class_from_element( + ns=ns, + element=sd, + strict=strict, + ) + elements.append(el_schema_data) + kwargs["elements"] = elements + return kwargs diff --git a/fastkml/enums.py b/fastkml/enums.py index 8c1ab589..e1ef370a 100644 --- a/fastkml/enums.py +++ b/fastkml/enums.py @@ -13,12 +13,32 @@ # 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 +""" +Enums for the fastkml package. + +This module contains the enums used in the fastkml package. + +https://developers.google.com/kml/documentation/kmlreference#kml-fields + +""" from enum import Enum from enum import unique +from typing import Union + +__all__ = ["AltitudeMode", "DateTimeResolution", "Verbosity"] + + +class REnum(Enum): + """Enum with custom repr for eval roundtrip.""" + + def __repr__(self) -> str: + """The string representation of the object.""" + cls_name = self.__class__.__name__ + return f"{cls_name}.{self.name}" @unique -class Verbosity(Enum): +class Verbosity(REnum): """Enum to represent the different verbosity levels.""" quiet = 0 @@ -27,7 +47,7 @@ class Verbosity(Enum): @unique -class DateTimeResolution(Enum): +class DateTimeResolution(REnum): """Enum to represent the different date time resolutions.""" datetime = "dateTime" @@ -37,7 +57,7 @@ class DateTimeResolution(Enum): @unique -class AltitudeMode(Enum): +class AltitudeMode(REnum): """ Enum to represent the different altitude modes. @@ -82,3 +102,127 @@ class AltitudeMode(Enum): absolute = "absolute" clamp_to_sea_floor = "clampToSeaFloor" relative_to_sea_floor = "relativeToSeaFloor" + + +@unique +class DataType(REnum): + string = "string" + int_ = "int" + uint = "uint" + short = "short" + ushort = "ushort" + float_ = "float" + double = "double" + bool_ = "bool" + + def convert(self, value: str) -> Union[str, int, float, bool]: + """Convert the data type to a python type.""" + if self == DataType.string: + return str(value) + if self in {DataType.int_, DataType.uint, DataType.short, DataType.ushort}: + return int(value) + if self in {DataType.float_, DataType.double}: + return float(value) + if self == DataType.bool_: + return bool(int(value)) + msg = f"Unknown data type {self}" + raise ValueError(msg) + + +@unique +class RefreshMode(REnum): + """ + Enum to represent the different refresh modes. + + Specifies how the link is refreshed when the "camera" changes. + """ + + on_change = "onChange" + on_interval = "onInterval" + on_expire = "onExpire" + + +@unique +class ViewRefreshMode(REnum): + """ + Enum to represent the different view refresh modes. + + Specifies how the link is refreshed when the "camera" changes. + """ + + never = "never" + on_stop = "onStop" + on_request = "onRequest" + on_region = "onRegion" + + +@unique +class ColorMode(REnum): + """ + Enum to represent the different color modes. + + Specifies how the color is applied to the geometry. + """ + + normal = "normal" + random = "random" + + +@unique +class DisplayMode(REnum): + """ + DisplayMode for BalloonStyle. + + If is default, Google Earth uses the information supplied in + to create a balloon . + If is hide, Google Earth does not display the balloon. + In Google Earth, clicking the List View icon for a Placemark whose balloon's + is hide causes Google Earth to fly to the Placemark. + """ + + default = "default" + hide = "hide" + + +@unique +class Shape(REnum): + """ + Shape for PhotoOverlay. + + The PhotoOverlay is projected onto the . + The can be one of the following: + - rectangle (default) - for an ordinary photo + - cylinder - for panoramas, which can be either partial or full cylinders + - sphere - for spherical panoramas + """ + + rectangle = "rectangle" + cylinder = "cylinder" + sphere = "sphere" + + +@unique +class GridOrigin(REnum): + """ + GridOrigin for GroundOverlay. + + Specifies where to begin numbering the tiles in each layer of the pyramid. + A value of lowerLeft specifies that row 1, column 1 of each layer is in + the bottom left corner of the grid. + """ + + lower_left = "lowerLeft" + upper_left = "upperLeft" + + +@unique +class Units(REnum): + """ + Units for ScreenOverlay and Hotspot. + + Specifies how the , values are interpreted. + """ + + fraction = "fraction" + pixels = "pixels" + inset_pixels = "insetPixels" diff --git a/fastkml/exceptions.py b/fastkml/exceptions.py index 600aaee3..56226835 100644 --- a/fastkml/exceptions.py +++ b/fastkml/exceptions.py @@ -26,3 +26,7 @@ class KMLParseError(FastKMLError): class KMLWriteError(FastKMLError): """Raised when there is an error writing KML.""" + + +class KMLSchemaError(FastKMLError): + """Raised when there is an error with the KML Schema.""" diff --git a/fastkml/geometry.py b/fastkml/geometry.py index 7a8aa5db..ef251ffb 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -37,17 +37,33 @@ from fastkml.exceptions import KMLWriteError from fastkml.types import Element +__all__ = [ + "AnyGeometryType", + "GeometryType", + "LineString", + "LinearRing", + "MultiGeometry", + "MultiGeometryType", + "Point", + "Polygon", + "create_multigeometry", +] + logger = logging.getLogger(__name__) GeometryType = Union[geo.Polygon, geo.LineString, geo.LinearRing, geo.Point] MultiGeometryType = Union[ - geo.MultiPoint, geo.MultiLineString, geo.MultiPolygon, geo.GeometryCollection + geo.MultiPoint, + geo.MultiLineString, + geo.MultiPolygon, + geo.GeometryCollection, ] AnyGeometryType = Union[GeometryType, MultiGeometryType] class _Geometry(_BaseObject): - """Baseclass with common methods for all geometry objects. + """ + Baseclass with common methods for all geometry objects. Attributes: extrude: boolean --> Specifies whether to connect the feature to the ground with a line. @@ -62,6 +78,7 @@ def __init__( self, *, ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, id: Optional[str] = None, target_id: Optional[str] = None, extrude: Optional[bool] = False, @@ -72,6 +89,7 @@ def __init__( """ Args: + ---- ns: Namespace of the object id: Id of the object target_id: Target id of the object @@ -80,7 +98,7 @@ def __init__( altitude_mode: Specifies how altitude components in the element are interpreted. """ - super().__init__(ns=ns, id=id, target_id=target_id) + super().__init__(ns=ns, id=id, name_spaces=name_spaces, target_id=target_id) self._extrude = extrude self._tessellate = tessellate self._altitude_mode = altitude_mode @@ -131,21 +149,23 @@ def _etree_coordinates( Element, config.etree.Element(f"{self.ns}coordinates"), # type: ignore[attr-defined] ) + if not coordinates: + return element if len(coordinates[0]) == 2: tuples = (f"{c[0]:f},{c[1]:f}" for c in coordinates) elif len(coordinates[0]) == 3: - tuples = ( - f"{c[0]:f},{c[1]:f},{c[2]:f}" for c in coordinates # type: ignore[misc] - ) + tuples = (f"{c[0]:f},{c[1]:f},{c[2]:f}" for c in coordinates) else: - raise KMLWriteError(f"Invalid dimensions in coordinates '{coordinates}'") + msg = f"Invalid dimensions in coordinates '{coordinates}'" + raise KMLWriteError(msg) element.text = " ".join(tuples) return element 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" + element, + f"{self.ns}altitudeMode", ) am_element.text = self.altitude_mode.value @@ -154,7 +174,8 @@ def _set_extrude(self, element: Element) -> None: et_element = cast( Element, config.etree.SubElement( # type: ignore[attr-defined] - element, f"{self.ns}extrude" + element, + f"{self.ns}extrude", ), ) et_element.text = str(int(self.extrude)) @@ -164,7 +185,8 @@ def _set_tessellate(self, element: Element) -> None: t_element = cast( Element, config.etree.SubElement( # type: ignore[attr-defined] - element, f"{self.ns}tessellate" + element, + f"{self.ns}tessellate", ), ) t_element.text = str(int(self.tessellate)) @@ -183,7 +205,11 @@ def etree_element( @classmethod def _get_coordinates( - cls, *, ns: str, element: Element, strict: bool + cls, + *, + ns: str, + element: Element, + strict: bool, ) -> List[PointType]: """ Get coordinates from element. @@ -265,7 +291,9 @@ def _get_geometry_kwargs( "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 + ns=ns, + element=element, + strict=strict, ), } @@ -284,13 +312,19 @@ def _get_kwargs( cls, *, ns: str, + name_spaces: Optional[Dict[str, str]] = None, element: Element, strict: bool, ) -> Dict[str, Any]: - kwargs = super()._get_kwargs(ns=ns, element=element, strict=strict) + kwargs = super()._get_kwargs( + ns=ns, + name_spaces=name_spaces, + 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)} + {"geometry": cls._get_geometry(ns=ns, element=element, strict=strict)}, ) return kwargs @@ -300,6 +334,7 @@ def __init__( self, *, ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, id: Optional[str] = None, target_id: Optional[str] = None, extrude: Optional[bool] = False, @@ -310,6 +345,7 @@ def __init__( super().__init__( ns=ns, id=id, + name_spaces=name_spaces, target_id=target_id, extrude=extrude, tessellate=tessellate, @@ -344,8 +380,11 @@ def _get_geometry( error = config.etree.tostring( # type: ignore[attr-defined] element, encoding="UTF-8", - ).decode("UTF-8") - raise KMLParseError(f"Invalid coordinates in {error}") from e + ).decode( + "UTF-8", + ) + msg = f"Invalid coordinates in {error}" + raise KMLParseError(msg) from e class LineString(_Geometry): @@ -353,6 +392,7 @@ def __init__( self, *, ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, id: Optional[str] = None, target_id: Optional[str] = None, extrude: Optional[bool] = False, @@ -362,6 +402,7 @@ def __init__( ) -> None: super().__init__( ns=ns, + name_spaces=name_spaces, id=id, target_id=target_id, extrude=extrude, @@ -397,8 +438,11 @@ def _get_geometry( error = config.etree.tostring( # type: ignore[attr-defined] element, encoding="UTF-8", - ).decode("UTF-8") - raise KMLParseError(f"Invalid coordinates in {error}") from e + ).decode( + "UTF-8", + ) + msg = f"Invalid coordinates in {error}" + raise KMLParseError(msg) from e class LinearRing(LineString): @@ -406,6 +450,7 @@ def __init__( self, *, ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, id: Optional[str] = None, target_id: Optional[str] = None, extrude: Optional[bool] = False, @@ -415,6 +460,7 @@ def __init__( ) -> None: super().__init__( ns=ns, + name_spaces=name_spaces, id=id, target_id=target_id, extrude=extrude, @@ -438,8 +484,11 @@ def _get_geometry( error = config.etree.tostring( # type: ignore[attr-defined] element, encoding="UTF-8", - ).decode("UTF-8") - raise KMLParseError(f"Invalid coordinates in {error}") from e + ).decode( + "UTF-8", + ) + msg = f"Invalid coordinates in {error}" + raise KMLParseError(msg) from e class Polygon(_Geometry): @@ -447,6 +496,7 @@ def __init__( self, *, ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, id: Optional[str] = None, target_id: Optional[str] = None, extrude: Optional[bool] = False, @@ -456,6 +506,7 @@ def __init__( ) -> None: super().__init__( ns=ns, + name_spaces=name_spaces, id=id, target_id=target_id, extrude=extrude, @@ -482,8 +533,9 @@ def etree_element( ) outer_boundary.append( linear_ring(geometry=self.geometry.exterior).etree_element( - precision=precision, verbosity=verbosity - ) + precision=precision, + verbosity=verbosity, + ), ) for interior in self.geometry.interiors: inner_boundary = cast( @@ -495,8 +547,9 @@ def etree_element( ) inner_boundary.append( linear_ring(geometry=interior).etree_element( - precision=precision, verbosity=verbosity - ) + precision=precision, + verbosity=verbosity, + ), ) return element @@ -507,15 +560,21 @@ def _get_geometry(cls, *, ns: str, element: Element, strict: bool) -> geo.Polygo error = config.etree.tostring( # type: ignore[attr-defined] element, encoding="UTF-8", - ).decode("UTF-8") - raise KMLParseError(f"Missing outerBoundaryIs in {error}") + ).decode( + "UTF-8", + ) + msg = f"Missing outerBoundaryIs in {error}" + raise KMLParseError(msg) outer_ring = outer_boundary.find(f"{ns}LinearRing") if outer_ring is None: error = config.etree.tostring( # type: ignore[attr-defined] element, encoding="UTF-8", - ).decode("UTF-8") - raise KMLParseError(f"Missing LinearRing in {error}") + ).decode( + "UTF-8", + ) + msg = f"Missing LinearRing in {error}" + raise KMLParseError(msg) exterior = LinearRing._get_geometry(ns=ns, element=outer_ring, strict=strict) interiors = [] for inner_boundary in element.findall(f"{ns}innerBoundaryIs"): @@ -524,10 +583,13 @@ def _get_geometry(cls, *, ns: str, element: Element, strict: bool) -> geo.Polygo error = config.etree.tostring( # type: ignore[attr-defined] element, encoding="UTF-8", - ).decode("UTF-8") - raise KMLParseError(f"Missing LinearRing in {error}") + ).decode( + "UTF-8", + ) + msg = f"Missing LinearRing in {error}" + raise KMLParseError(msg) interiors.append( - LinearRing._get_geometry(ns=ns, element=inner_ring, strict=strict) + LinearRing._get_geometry(ns=ns, element=inner_ring, strict=strict), ) return geo.Polygon.from_linear_rings(exterior, *interiors) @@ -535,12 +597,15 @@ def _get_geometry(cls, *, ns: str, element: Element, strict: bool) -> geo.Polygo def create_multigeometry( geometries: Sequence[AnyGeometryType], ) -> Optional[MultiGeometryType]: - """Create a MultiGeometry from a sequence of geometries. + """ + Create a MultiGeometry from a sequence of geometries. Args: + ---- geometries: Sequence of geometries. Returns: + ------- MultiGeometry """ @@ -556,7 +621,7 @@ def create_multigeometry( } for geometry_name, constructor in map_to_geometries.items(): if geom_type == geometry_name: - return constructor( # type: ignore[operator, no-any-return] + return constructor( *geometries, ) @@ -581,6 +646,7 @@ def __init__( self, *, ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, id: Optional[str] = None, target_id: Optional[str] = None, extrude: Optional[bool] = False, @@ -590,6 +656,7 @@ def __init__( ) -> None: super().__init__( ns=ns, + name_spaces=name_spaces, id=id, target_id=target_id, extrude=extrude, @@ -618,20 +685,28 @@ def etree_element( extrude=None, tessellate=None, altitude_mode=None, - geometry=geometry, # type: ignore[arg-type] - ).etree_element(precision=precision, verbosity=verbosity) + geometry=geometry, + ).etree_element(precision=precision, verbosity=verbosity), ) return element @classmethod def _get_geometry( - cls, *, ns: str, element: Element, strict: bool + cls, + *, + ns: str, + element: Element, + strict: bool, ) -> Optional[MultiGeometryType]: geometries = [] - allowed_geometries = (cls,) + tuple(cls.map_to_kml.values()) + 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) + geometry = g._get_geometry( # type: ignore[attr-defined] + ns=ns, + element=e, + strict=strict, + ) if geometry is not None: geometries.append(geometry) return create_multigeometry(geometries) diff --git a/fastkml/gx.py b/fastkml/gx.py index 892850e9..e0a64e8d 100644 --- a/fastkml/gx.py +++ b/fastkml/gx.py @@ -88,15 +88,26 @@ from typing import Sequence from typing import cast -import dateutil.parser +import arrow import pygeoif.geometry as geo -import fastkml.config as config +from fastkml import config from fastkml.enums import AltitudeMode from fastkml.enums import Verbosity from fastkml.geometry import _Geometry from fastkml.types import Element +__all__ = [ + "Angle", + "MultiTrack", + "Track", + "TrackItem", + "linestring_to_track_items", + "multilinestring_to_tracks", + "track_items_to_geometry", + "tracks_to_geometry", +] + logger = logging.getLogger(__name__) @@ -133,30 +144,30 @@ def etree_elements( 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" + 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" + 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" + f"{name_spaces.get('gx', '')}angles", ) if self.angle: element.text = " ".join( - [str(self.angle.heading), str(self.angle.tilt), str(self.angle.roll)] + [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] + *[item.coord for item in track_items if item.coord is not None], ) @@ -182,6 +193,7 @@ def __init__( self, *, ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, id: Optional[str] = None, target_id: Optional[str] = None, extrude: Optional[bool] = False, @@ -191,7 +203,8 @@ def __init__( track_items: Optional[Sequence[TrackItem]] = None, ) -> None: if geometry and track_items: - raise ValueError("Cannot specify both geometry and track_items") + msg = "Cannot specify both geometry and track_items" + raise ValueError(msg) if geometry: track_items = linestring_to_track_items(geometry) elif track_items: @@ -199,6 +212,7 @@ def __init__( self.track_items = track_items super().__init__( ns=ns, + name_spaces=name_spaces, id=id, target_id=target_id, extrude=extrude, @@ -231,7 +245,9 @@ def etree_element( 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 + precision=precision, + verbosity=verbosity, + name_spaces=name_spaces, ): element.append(track_item_element) return element @@ -247,14 +263,14 @@ def track_items_kwargs_from_element( 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)) + time_stamps.append(arrow.get(time_stamp.text).datetime) 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()]) + geo.Point(*[float(c) for c in coord.text.strip().split()]), ) else: coords.append(None) @@ -274,25 +290,34 @@ def _get_kwargs( cls, *, ns: str, + name_spaces: Optional[Dict[str, str]] = None, element: Element, strict: bool, ) -> Dict[str, Any]: - kwargs = super()._get_kwargs(ns=ns, element=element, strict=strict) + kwargs = super()._get_kwargs( + ns=ns, + name_spaces=name_spaces, + element=element, + strict=strict, + ) kwargs["track_items"] = cls.track_items_kwargs_from_element( - ns=ns, element=element, strict=strict + ns=ns, + element=element, + strict=strict, ) return kwargs def multilinestring_to_tracks( - multilinestring: geo.MultiLineString, ns: Optional[str] + 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] + *[cast(geo.LineString, track.geometry) for track in tracks if track.geometry], ) @@ -301,6 +326,7 @@ def __init__( self, *, ns: Optional[str] = None, + name_spaces: Optional[Dict[str, str]] = None, id: Optional[str] = None, target_id: Optional[str] = None, extrude: Optional[bool] = False, @@ -311,7 +337,8 @@ def __init__( interpolate: Optional[bool] = None, ) -> None: if geometry and tracks: - raise ValueError("Cannot specify both geometry and track_items") + msg = "Cannot specify both geometry and track_items" + raise ValueError(msg) if geometry: tracks = multilinestring_to_tracks(geometry, ns=ns) elif tracks: @@ -320,6 +347,7 @@ def __init__( self.interpolate = interpolate super().__init__( ns=ns, + name_spaces=name_spaces, id=id, target_id=target_id, extrude=extrude, @@ -354,15 +382,18 @@ def etree_element( i_element = cast( Element, config.etree.SubElement( # type: ignore[attr-defined] - element, f"{self.ns}interpolate" + 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 - ) + precision=precision, + verbosity=verbosity, + name_spaces=name_spaces, + ), ) return element @@ -407,14 +438,24 @@ def _get_kwargs( cls, *, ns: str, + name_spaces: Optional[Dict[str, str]] = None, element: Element, strict: bool, ) -> Dict[str, Any]: - kwargs = super()._get_kwargs(ns=ns, element=element, strict=strict) + kwargs = super()._get_kwargs( + ns=ns, + name_spaces=name_spaces, + element=element, + strict=strict, + ) kwargs["interpolate"] = cls._get_interpolate( - ns=ns, element=element, strict=strict + ns=ns, + element=element, + strict=strict, ) kwargs["tracks"] = cls._get_track_kwargs_from_element( - ns=config.GXNS, element=element, strict=strict + ns=kwargs["name_spaces"].get("gx", ""), + element=element, + strict=strict, ) return kwargs diff --git a/fastkml/helpers.py b/fastkml/helpers.py deleted file mode 100644 index 781ea623..00000000 --- a/fastkml/helpers.py +++ /dev/null @@ -1,149 +0,0 @@ -# Copyright (C) 2020 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 - -"""Helper functions and classes.""" -import logging -from typing import Any -from typing import Callable -from typing import Optional - -from fastkml import config -from fastkml.types import Element - -logger = logging.getLogger(__name__) - - -def o_to_attr( - obj: object, - element: Element, - kml_attr: str, - obj_attr: str, - required: bool, - **kwargs: Any, -) -> None: - """Set an attribute on an KML Element from an object attribute.""" - attribute = getattr(obj, obj_attr) - if attribute: - element.set(kml_attr, str(attribute)) - elif required: - logger.warning( - "Required attribute '%s' for '%s' missing.", - obj_attr, - obj.__class__.__name__, - ) - - -def o_from_attr( - obj: object, - element: Element, - kml_attr: str, - obj_attr: str, - required: bool, - **kwargs: Any, -) -> None: - """Set an attribute on self from an KML attribute.""" - attribute = element.get(kml_attr) - if attribute: - setattr(obj, obj_attr, attribute) - elif required: - logger.warning( - "Required attribute '%s' for '%s' missing.", - kml_attr, - obj.__class__.__name__, - ) - - -def o_int_from_attr( - obj: object, - element: Element, - kml_attr: str, - obj_attr: str, - required: bool, - **kwargs: Any, -) -> None: - """Set an attribute on self from an KML attribute.""" - try: - attribute = int(element.get(kml_attr)) - except (ValueError, TypeError): - attribute = None - if attribute is not None: - setattr(obj, obj_attr, attribute) - elif required: - logger.warning( - "Required attribute '%s' for '%s' missing.", - kml_attr, - obj.__class__.__name__, - ) - - -def o_from_subelement_text( - obj: object, - element: Element, - kml_attr: str, - obj_attr: str, - required: bool, - validator: Optional[Callable[..., bool]] = None, - **kwargs: Any, -) -> None: - """Set an attribute on self from the text of a SubElement.""" - elem = element.find(f"{obj.ns}{kml_attr}") # type: ignore[attr-defined] - if elem is not None: - if validator is not None and not validator(elem.text): - logger.warning( - "Invalid value for attribute '%s' for '%s'", - kml_attr, - obj.__class__.__name__, - ) - else: - setattr(obj, obj_attr, elem.text) - elif required: - logger.warning( - "Required attribute '%s' for '%s' missing.", - kml_attr, - obj.__class__.__name__, - ) - - -def o_to_subelement_text( - obj: object, - element: Element, - kml_attr: str, - obj_attr: str, - required: bool, - validator: Optional[Callable[..., bool]] = None, - **kwargs: Any, -) -> None: - """Set the text of a SubElement from an object attribute.""" - attribute = getattr(obj, obj_attr) - if attribute: - if validator is not None and not validator(attribute): - logger.warning( - "Invalid value for attribute '%s' for '%s'", - obj_attr, - obj.__class__.__name__, - ) - else: - elem = config.etree.SubElement( # type: ignore[attr-defined] - element, - f"{obj.ns}{kml_attr}", # type: ignore[attr-defined] - ) - elem.text = str(attribute) - elif required: - logger.warning( - "Required attribute '%s' for '%s' missing.", - obj_attr, - obj.__class__.__name__, - ) diff --git a/fastkml/kml.py b/fastkml/kml.py index 848f1614..2d3b02b9 100644 --- a/fastkml/kml.py +++ b/fastkml/kml.py @@ -26,18 +26,25 @@ """ import logging import urllib.parse as urlparse +from datetime import datetime +from typing import Any +from typing import Dict from typing import Iterator from typing import List from typing import Optional from typing import Union -import fastkml.atom as atom -import fastkml.config as config -import fastkml.gx as gx +from fastkml import atom +from fastkml import config +from fastkml import gx from fastkml.base import _BaseObject from fastkml.data import ExtendedData from fastkml.data import Schema +from fastkml.enums import GridOrigin +from fastkml.enums import RefreshMode +from fastkml.enums import Shape from fastkml.enums import Verbosity +from fastkml.enums import ViewRefreshMode from fastkml.geometry import AnyGeometryType from fastkml.geometry import LinearRing from fastkml.geometry import LineString @@ -76,7 +83,7 @@ class _Feature(TimeMixin, _BaseObject): * Placemark * Overlay Not Implemented Yet: - * NetworkLink + * NetworkLink. """ name = None @@ -199,14 +206,17 @@ def __init__( @property def style_url(self) -> Optional[str]: - """Returns the url only, not a full StyleUrl object. - if you need the full StyleUrl object use _style_url""" + """ + Returns the url only, not a full StyleUrl object. + if you need the full StyleUrl object use _style_url. + """ if isinstance(self._style_url, StyleUrl): return self._style_url.url + return None @style_url.setter def style_url(self, styleurl: Union[str, StyleUrl, None]) -> None: - """you may pass a StyleUrl Object, a string or None""" + """You may pass a StyleUrl Object, a string or None.""" if isinstance(styleurl, StyleUrl): self._style_url = styleurl elif isinstance(styleurl, str): @@ -222,16 +232,16 @@ def camera(self): return self._camera @camera.setter - def camera(self, camera): + def camera(self, camera) -> None: if isinstance(camera, Camera): self._camera = camera @property - def look_at(self): + def look_at(self) -> datetime: return self._look_at @look_at.setter - def look_at(self, look_at): + def look_at(self, look_at) -> None: if isinstance(look_at, LookAt): self._look_at = look_at @@ -240,7 +250,7 @@ def link(self): return self._atom_link.href @link.setter - def link(self, url): + def link(self, url) -> None: if isinstance(url, str): self._atom_link = atom.Link(href=url) elif isinstance(url, atom.Link): @@ -251,17 +261,18 @@ def link(self, url): raise TypeError @property - def author(self): + def author(self) -> None: if self._atom_author: return self._atom_author.name + return None @author.setter - def author(self, name): + def author(self, name) -> None: if isinstance(name, atom.Author): self._atom_author = name elif isinstance(name, str): if self._atom_author is None: - self._atom_author = atom.Author(name=name) + self._atom_author = atom.Author(ns=config.ATOMNS, name=name) else: self._atom_author.name = name elif name is None: @@ -270,14 +281,14 @@ def author(self, name): raise TypeError def append_style(self, style: Union[Style, StyleMap]) -> None: - """append a style to the feature""" + """Append a style to the feature.""" if isinstance(style, _StyleSelector): self._styles.append(style) else: raise TypeError def styles(self) -> Iterator[Union[Style, StyleMap]]: - """iterate over the styles of this feature""" + """Iterate over the styles of this feature.""" for style in self._styles: if isinstance(style, _StyleSelector): yield style @@ -285,9 +296,9 @@ def styles(self) -> Iterator[Union[Style, StyleMap]]: raise TypeError @property - def snippet(self): + def snippet(self) -> Optional[Dict[str, Any]]: if not self._snippet: - return + return None if isinstance(self._snippet, dict): text = self._snippet.get("text") if text: @@ -298,16 +309,18 @@ def snippet(self): elif int(max_lines) > 0: # if maxLines <=0 ignore it return {"text": text, "maxLines": max_lines} + return None + return None elif isinstance(self._snippet, str): return self._snippet else: + msg = "Snippet must be dict of {'text':t, 'maxLines':i} or string" raise ValueError( - "Snippet must be dict of " - "{'text':t, 'maxLines':i} or string" # noqa: FS003 + msg, ) @snippet.setter - def snippet(self, snip=None): + def snippet(self, snip=None) -> None: self._snippet = {} if isinstance(snip, dict): self._snippet["text"] = snip.get("text") @@ -319,18 +332,19 @@ def snippet(self, snip=None): elif snip is None: self._snippet = None else: + msg = "Snippet must be dict of {'text':t, 'maxLines':i} or string" raise ValueError( - "Snippet must be dict of " - "{'text':t, 'maxLines':i} or string" # noqa: FS003 + msg, ) @property - def address(self): + def address(self) -> None: if self._address: return self._address + return None @address.setter - def address(self, address): + def address(self, address) -> None: if isinstance(address, str): self._address = address elif address is None: @@ -339,12 +353,13 @@ def address(self, address): raise ValueError @property - def phone_number(self): + def phone_number(self) -> None: if self._phone_number: return self._phone_number + return None @phone_number.setter - def phone_number(self, phone_number): + def phone_number(self, phone_number) -> None: if isinstance(phone_number, str): self._phone_number = phone_number elif phone_number is None: @@ -365,7 +380,8 @@ def etree_element( description = config.etree.SubElement(element, f"{self.ns}description") description.text = self.description if (self.camera is not None) and (self.look_at is not None): - raise ValueError("Either Camera or LookAt can be defined, not both") + msg = "Either Camera or LookAt can be defined, not both" + raise ValueError(msg) if self.camera is not None: element.append(self._camera.etree_element()) elif self.look_at is not None: @@ -389,7 +405,8 @@ def etree_element( if self.snippet.get("maxLines"): snippet.set("maxLines", str(self.snippet["maxLines"])) if (self._timespan is not None) and (self._timestamp is not None): - raise ValueError("Either Timestamp or Timespan can be defined, not both") + msg = "Either Timestamp or Timespan can be defined, not both" + raise ValueError(msg) elif self._timespan is not None: element.append(self._timespan.etree_element()) elif self._timestamp is not None: @@ -408,7 +425,7 @@ def etree_element( phone_number.text = self._phone_number return element - def from_element(self, element: Element) -> None: + def from_element(self, element: Element, strict: bool = False) -> None: super().from_element(element) name = element.find(f"{self.ns}name") if name is not None: @@ -424,19 +441,30 @@ def from_element(self, element: Element) -> None: self.isopen = 1 if isopen.text in ["1", "true"] else 0 styles = element.findall(f"{self.ns}Style") for style in styles: - s = Style(self.ns) - s.from_element(style) + s = Style.class_from_element( + ns=self.ns, + name_spaces=self.name_spaces, + element=style, + strict=strict, + ) self.append_style(s) styles = element.findall(f"{self.ns}StyleMap") for style in styles: - s = StyleMap(self.ns) - s.from_element(style) + s = StyleMap.class_from_element( + ns=self.ns, + name_spaces=self.name_spaces, + element=style, + strict=strict, + ) self.append_style(s) style_url = element.find(f"{self.ns}styleUrl") if style_url is not None: - s = StyleUrl(self.ns) - s.from_element(style_url) - self._style_url = s + self._style_url = StyleUrl.class_from_element( + ns=self.ns, + name_spaces=self.name_spaces, + element=style_url, + strict=strict, + ) snippet = element.find(f"{self.ns}Snippet") if snippet is not None: _snippet = {"text": snippet.text} @@ -445,29 +473,43 @@ def from_element(self, element: Element) -> None: self.snippet = _snippet timespan = element.find(f"{self.ns}TimeSpan") if timespan is not None: - s = TimeSpan(self.ns) - s.from_element(timespan) - self._timespan = s + self._timespan = TimeSpan.class_from_element( + ns=self.ns, + name_spaces=self.name_spaces, + element=timespan, + strict=strict, + ) timestamp = element.find(f"{self.ns}TimeStamp") if timestamp is not None: - s = TimeStamp(self.ns) - s.from_element(timestamp) - self._timestamp = s + self._timestamp = TimeStamp.class_from_element( + ns=self.ns, + name_spaces=self.name_spaces, + element=timestamp, + strict=strict, + ) atom_link = element.find(f"{atom.NS}link") if atom_link is not None: - s = atom.Link() - s.from_element(atom_link) - self._atom_link = s + self._atom_link = atom.Link.class_from_element( + ns=atom.NS, + name_spaces=self.name_spaces, + element=atom_link, + strict=strict, + ) atom_author = element.find(f"{atom.NS}author") if atom_author is not None: - s = atom.Author() - s.from_element(atom_author) - self._atom_author = s + self._atom_author = atom.Author.class_from_element( + ns=atom.NS, + name_spaces=self.name_spaces, + element=atom_author, + strict=strict, + ) extended_data = element.find(f"{self.ns}ExtendedData") if extended_data is not None: - x = ExtendedData(self.ns) - x.from_element(extended_data) - self.extended_data = x + self.extended_data = ExtendedData.class_from_element( + ns=self.ns, + element=extended_data, + strict=strict, + ) address = element.find(f"{self.ns}address") if address is not None: self.address = address.text @@ -497,9 +539,9 @@ class Icon(_BaseObject): __name__ = "Icon" _href = None - _refresh_mode: str = None + _refresh_mode: Optional[RefreshMode] _refresh_interval = None - _view_refresh_mode = None + _view_refresh_mode: Optional[ViewRefreshMode] _view_refresh_time = None _view_bound_scale = None _view_format = None @@ -511,9 +553,9 @@ def __init__( id: Optional[str] = None, target_id: Optional[str] = None, href: Optional[str] = None, - refresh_mode: Optional[str] = None, + refresh_mode: Optional[RefreshMode] = None, refresh_interval: Optional[float] = None, - view_refresh_mode: Optional[str] = None, + view_refresh_mode: Optional[ViewRefreshMode] = None, view_refresh_time: Optional[float] = None, view_bound_scale: Optional[float] = None, view_format: Optional[str] = None, @@ -545,7 +587,7 @@ def href(self, href) -> None: raise ValueError @property - def refresh_mode(self) -> Optional[str]: + def refresh_mode(self) -> Optional[RefreshMode]: """ Specifies a time-based refresh mode. @@ -558,13 +600,8 @@ def refresh_mode(self) -> Optional[str]: return self._refresh_mode @refresh_mode.setter - def refresh_mode(self, refresh_mode) -> None: - if isinstance(refresh_mode, str): - self._refresh_mode = refresh_mode - elif refresh_mode is None: - self._refresh_mode = None - else: - raise ValueError + def refresh_mode(self, refresh_mode: Optional[RefreshMode]) -> None: + self._refresh_mode = refresh_mode @property def refresh_interval(self) -> Optional[float]: @@ -581,7 +618,7 @@ def refresh_interval(self, refresh_interval: Optional[float]) -> None: raise ValueError @property - def view_refresh_mode(self): + def view_refresh_mode(self) -> Optional[ViewRefreshMode]: """ Specifies how the link is refreshed when the "camera" changes. @@ -593,18 +630,13 @@ def view_refresh_mode(self): - onRequest - Refresh the file only when the user explicitly requests it. (For example, in Google Earth, the user right-clicks and selects Refresh in the Context menu.) - - onRegion - Refresh the file when the Region becomes active. + - onRegion - Refresh the file when the Region becomes active. """ return self._view_refresh_mode @view_refresh_mode.setter - def view_refresh_mode(self, view_refresh_mode): - if isinstance(view_refresh_mode, str): - self._view_refresh_mode = view_refresh_mode - elif view_refresh_mode is None: - self._view_refresh_mode = None - else: - raise ValueError + def view_refresh_mode(self, view_refresh_mode: Optional[ViewRefreshMode]) -> None: + self._view_refresh_mode = view_refresh_mode @property def view_refresh_time(self): @@ -615,7 +647,7 @@ def view_refresh_time(self): return self._view_refresh_time @view_refresh_time.setter - def view_refresh_time(self, view_refresh_time: Optional[float]): + def view_refresh_time(self, view_refresh_time: Optional[float]) -> None: if isinstance(view_refresh_time, float): self._view_refresh_time = view_refresh_time elif view_refresh_time is None: @@ -648,7 +680,7 @@ def view_format(self): """ Specifies the format of the query string that is appended to the Link's before the file is fetched. - (If the specifies a local file, this element is ignored.) + (If the specifies a local file, this element is ignored.). This information matches the Web Map Service (WMS) bounding box specification. If you specify an empty tag, no information is appended to the @@ -675,7 +707,7 @@ def view_format(self): return self._view_format @view_format.setter - def view_format(self, view_format): + def view_format(self, view_format) -> None: if isinstance(view_format, str): self._view_format = view_format elif view_format is None: @@ -697,7 +729,7 @@ def http_query(self): return self._http_query @http_query.setter - def http_query(self, http_query): + def http_query(self, http_query) -> None: if isinstance(http_query, str): self._http_query = http_query elif http_query is None: @@ -717,25 +749,29 @@ def etree_element( href.text = self._href if self._refresh_mode: refresh_mode = config.etree.SubElement(element, f"{self.ns}refreshMode") - refresh_mode.text = self._refresh_mode + refresh_mode.text = self._refresh_mode.value if self._refresh_interval: refresh_interval = config.etree.SubElement( - element, f"{self.ns}refreshInterval" + element, + f"{self.ns}refreshInterval", ) refresh_interval.text = str(self._refresh_interval) if self._view_refresh_mode: view_refresh_mode = config.etree.SubElement( - element, f"{self.ns}viewRefreshMode" + element, + f"{self.ns}viewRefreshMode", ) - view_refresh_mode.text = self._view_refresh_mode + view_refresh_mode.text = self._view_refresh_mode.value if self._view_refresh_time: view_refresh_time = config.etree.SubElement( - element, f"{self.ns}viewRefreshTime" + element, + f"{self.ns}viewRefreshTime", ) view_refresh_time.text = str(self._view_refresh_time) if self._view_bound_scale: view_bound_scale = config.etree.SubElement( - element, f"{self.ns}viewBoundScale" + element, + f"{self.ns}viewBoundScale", ) view_bound_scale.text = str(self._view_bound_scale) if self._view_format: @@ -756,7 +792,7 @@ def from_element(self, element: Element) -> None: refresh_mode = element.find(f"{self.ns}refreshMode") if refresh_mode is not None: - self.refresh_mode = refresh_mode.text + self.refresh_mode = RefreshMode(refresh_mode.text) refresh_interval = element.find(f"{self.ns}refreshInterval") if refresh_interval is not None: @@ -767,7 +803,7 @@ def from_element(self, element: Element) -> None: view_refresh_mode = element.find(f"{self.ns}viewRefreshMode") if view_refresh_mode is not None: - self.view_refresh_mode = view_refresh_mode.text + self.view_refresh_mode = ViewRefreshMode(view_refresh_mode.text) view_refresh_time = element.find(f"{self.ns}viewRefreshTime") if view_refresh_time is not None: @@ -799,7 +835,7 @@ class _Container(_Feature): creation of nested hierarchies. subclasses are: Document, - Folder + Folder. """ _features = [] @@ -827,15 +863,18 @@ def __init__( self._features = features or [] def features(self) -> Iterator[_Feature]: - """iterate over features""" + """Iterate over features.""" for feature in self._features: if isinstance(feature, (Folder, Placemark, Document, _Overlay)): yield feature else: - raise TypeError( + msg = ( "Features must be instances of " "(Folder, Placemark, Document, Overlay)" ) + raise TypeError( + msg, + ) def etree_element( self, @@ -848,21 +887,22 @@ def etree_element( return element def append(self, kmlobj: _Feature) -> None: - """append a feature""" + """Append a feature.""" if id(kmlobj) == id(self): - raise ValueError("Cannot append self") + msg = "Cannot append self" + raise ValueError(msg) if isinstance(kmlobj, (Folder, Placemark, Document, _Overlay)): self._features.append(kmlobj) else: + msg = "Features must be instances of (Folder, Placemark, Document, Overlay)" raise TypeError( - "Features must be instances of " - "(Folder, Placemark, Document, Overlay)" + msg, ) class _Overlay(_Feature): """ - abstract element; do not create + abstract element; do not create. Base type for image overlays drawn on the planet surface or on the screen @@ -915,7 +955,7 @@ def color(self): return self._color @color.setter - def color(self, color): + def color(self, color) -> None: if isinstance(color, str): self._color = color elif color is None: @@ -924,11 +964,11 @@ def color(self, color): raise ValueError @property - def draw_order(self): + def draw_order(self) -> Optional[str]: return self._draw_order @draw_order.setter - def draw_order(self, value): + def draw_order(self, value) -> None: if isinstance(value, (str, int, float)): self._draw_order = str(value) elif value is None: @@ -937,11 +977,11 @@ def draw_order(self, value): raise ValueError @property - def icon(self): + def icon(self) -> Optional[Icon]: return self._icon @icon.setter - def icon(self, value): + def icon(self, value) -> None: if isinstance(value, Icon): self._icon = value elif value is None: @@ -1049,7 +1089,7 @@ class PhotoOverlay(_Overlay): _max_height = None # Height in pixels of the original image. - _grid_origin = None + _grid_origin: Optional[GridOrigin] # Specifies where to begin numbering the tiles in each layer of the pyramid. # A value of lowerLeft specifies that row 1, column 1 of each layer is in # the bottom left corner of the grid. @@ -1060,7 +1100,7 @@ class PhotoOverlay(_Overlay): # The icon drawn is specified by the and fields, # just as it is for . - _shape = "rectangle" + _shape: Optional[Shape] # The PhotoOverlay is projected onto the . # The can be one of the following: # rectangle (default) - @@ -1071,11 +1111,11 @@ class PhotoOverlay(_Overlay): # for spherical panoramas @property - def rotation(self): + def rotation(self) -> Optional[str]: return self._rotation @rotation.setter - def rotation(self, value): + def rotation(self, value) -> None: if isinstance(value, (str, int, float)): self._rotation = str(value) elif value is None: @@ -1084,11 +1124,11 @@ def rotation(self, value): raise ValueError @property - def left_fov(self): + def left_fov(self) -> Optional[str]: return self._left_fow @left_fov.setter - def left_fov(self, value): + def left_fov(self, value) -> None: if isinstance(value, (str, int, float)): self._left_fow = str(value) elif value is None: @@ -1097,11 +1137,11 @@ def left_fov(self, value): raise ValueError @property - def right_fov(self): + def right_fov(self) -> Optional[str]: return self._right_fov @right_fov.setter - def right_fov(self, value): + def right_fov(self, value) -> None: if isinstance(value, (str, int, float)): self._right_fov = str(value) elif value is None: @@ -1110,11 +1150,11 @@ def right_fov(self, value): raise ValueError @property - def bottom_fov(self): + def bottom_fov(self) -> Optional[str]: return self._bottom_fov @bottom_fov.setter - def bottom_fov(self, value): + def bottom_fov(self, value) -> None: if isinstance(value, (str, int, float)): self._bottom_fov = str(value) elif value is None: @@ -1123,11 +1163,11 @@ def bottom_fov(self, value): raise ValueError @property - def top_fov(self): + def top_fov(self) -> Optional[str]: return self._top_fov @top_fov.setter - def top_fov(self, value): + def top_fov(self, value) -> None: if isinstance(value, (str, int, float)): self._top_fov = str(value) elif value is None: @@ -1136,11 +1176,11 @@ def top_fov(self, value): raise ValueError @property - def near(self): + def near(self) -> Optional[str]: return self._near @near.setter - def near(self, value): + def near(self, value) -> None: if isinstance(value, (str, int, float)): self._near = str(value) elif value is None: @@ -1149,11 +1189,11 @@ def near(self, value): raise ValueError @property - def tile_size(self): + def tile_size(self) -> Optional[str]: return self._tile_size @tile_size.setter - def tile_size(self, value): + def tile_size(self, value) -> None: if isinstance(value, (str, int, float)): self._tile_size = str(value) elif value is None: @@ -1162,11 +1202,11 @@ def tile_size(self, value): raise ValueError @property - def max_width(self): + def max_width(self) -> Optional[str]: return self._max_width @max_width.setter - def max_width(self, value): + def max_width(self, value) -> None: if isinstance(value, (str, int, float)): self._max_width = str(value) elif value is None: @@ -1175,11 +1215,11 @@ def max_width(self, value): raise ValueError @property - def max_height(self): + def max_height(self) -> Optional[str]: return self._max_height @max_height.setter - def max_height(self, value): + def max_height(self, value) -> None: if isinstance(value, (str, int, float)): self._max_height = str(value) elif value is None: @@ -1188,50 +1228,40 @@ def max_height(self, value): raise ValueError @property - def grid_origin(self): + def grid_origin(self) -> Optional[GridOrigin]: return self._grid_origin @grid_origin.setter - def grid_origin(self, value): - if value in ("lowerLeft", "upperLeft"): - self._grid_origin = str(value) - elif value is None: - self._grid_origin = None - else: - raise ValueError + def grid_origin(self, value: Optional[GridOrigin]) -> None: + self._grid_origin = value @property - def point(self): + def point(self) -> str: return self._point @point.setter - def point(self, value): + def point(self, value) -> None: if isinstance(value, (str, tuple)): self._point = str(value) else: raise ValueError @property - def shape(self): + def shape(self) -> Optional[Shape]: return self._shape @shape.setter - def shape(self, value): - if value in ("rectangle", "cylinder", "sphere"): - self._shape = str(value) - elif value is None: - self._shape = None - else: - raise ValueError("Shape must be one of " "rectangle, cylinder, sphere") + def shape(self, value: Optional[Shape]) -> None: + self._shape = value - def view_volume(self, left_fov, right_fov, bottom_fov, top_fov, near): + def view_volume(self, left_fov, right_fov, bottom_fov, top_fov, near) -> None: self.left_fov = left_fov self.right_fov = right_fov self.bottom_fov = bottom_fov self.top_fov = top_fov self.near = near - def image_pyramid(self, tile_size, max_width, max_height, grid_origin): + def image_pyramid(self, tile_size, max_width, max_height, grid_origin) -> None: self.tile_size = tile_size self.max_width = max_width self.max_height = max_height @@ -1241,7 +1271,7 @@ def etree_element( self, precision: Optional[int] = None, verbosity: Verbosity = Verbosity.normal, - ): + ) -> Element: element = super().etree_element(precision=precision, verbosity=verbosity) if self._rotation: rotation = config.etree.SubElement(element, f"{self.ns}rotation") @@ -1253,7 +1283,7 @@ def etree_element( self._bottom_fov, self._top_fov, self._near, - ] + ], ): view_volume = config.etree.SubElement(element, f"{self.ns}ViewVolume") left_fov = config.etree.SubElement(view_volume, f"{self.ns}leftFov") @@ -1278,7 +1308,7 @@ def etree_element( grid_origin.text = self._grid_origin return element - def from_element(self, element): + def from_element(self, element) -> None: super().from_element(element) rotation = element.find(f"{self.ns}rotation") if rotation is not None: @@ -1385,11 +1415,11 @@ class GroundOverlay(_Overlay): _lat_lon_quad = None @property - def altitude(self): + def altitude(self) -> Optional[str]: return self._altitude @altitude.setter - def altitude(self, value): + def altitude(self, value) -> None: if isinstance(value, (str, int, float)): self._altitude = str(value) elif value is None: @@ -1398,22 +1428,22 @@ def altitude(self, value): raise ValueError @property - def altitude_mode(self): + def altitude_mode(self) -> str: return self._altitude_mode @altitude_mode.setter - def altitude_mode(self, mode): + def altitude_mode(self, mode) -> None: if mode in ("clampToGround", "absolute"): self._altitude_mode = str(mode) else: self._altitude_mode = "clampToGround" @property - def north(self): + def north(self) -> Optional[str]: return self._north @north.setter - def north(self, value): + def north(self, value) -> None: if isinstance(value, (str, int, float)): self._north = str(value) elif value is None: @@ -1422,11 +1452,11 @@ def north(self, value): raise ValueError @property - def south(self): + def south(self) -> Optional[str]: return self._south @south.setter - def south(self, value): + def south(self, value) -> None: if isinstance(value, (str, int, float)): self._south = str(value) elif value is None: @@ -1435,11 +1465,11 @@ def south(self, value): raise ValueError @property - def east(self): + def east(self) -> Optional[str]: return self._east @east.setter - def east(self, value): + def east(self, value) -> None: if isinstance(value, (str, int, float)): self._east = str(value) elif value is None: @@ -1448,11 +1478,11 @@ def east(self, value): raise ValueError @property - def west(self): + def west(self) -> Optional[str]: return self._west @west.setter - def west(self, value): + def west(self, value) -> None: if isinstance(value, (str, int, float)): self._west = str(value) elif value is None: @@ -1461,11 +1491,11 @@ def west(self, value): raise ValueError @property - def rotation(self): + def rotation(self) -> Optional[str]: return self._rotation @rotation.setter - def rotation(self, value): + def rotation(self, value) -> None: if isinstance(value, (str, int, float)): self._rotation = str(value) elif value is None: @@ -1474,7 +1504,12 @@ def rotation(self, value): raise ValueError def lat_lon_box( - self, north: int, south: int, east: int, west: int, rotation: int = 0 + self, + north: int, + south: int, + east: int, + west: int, + rotation: int = 0, ) -> None: if -90 <= float(north) <= 90: self.north = north @@ -1508,7 +1543,8 @@ def etree_element( altitude.text = self._altitude if self._altitude_mode: altitude_mode = config.etree.SubElement( - element, f"{self.ns}altitudeMode" + element, + f"{self.ns}altitudeMode", ) altitude_mode.text = self._altitude_mode if all([self._north, self._south, self._east, self._west]): @@ -1557,13 +1593,13 @@ class Document(_Container): """ A Document is a container for features and styles. This element is required if your KML file uses shared styles or schemata for typed - extended data + extended data. """ __name__ = "Document" _schemata = None - def schemata(self) -> None: + def schemata(self) -> Iterator["Schema"]: if self._schemata: yield from self._schemata @@ -1576,7 +1612,7 @@ def append_schema(self, schema: "Schema") -> None: s = Schema(schema) self._schemata.append(s) - def from_element(self, element: Element) -> None: + def from_element(self, element: Element, strict: bool = False) -> None: super().from_element(element) documents = element.findall(f"{self.ns}Document") for document in documents: @@ -1595,8 +1631,7 @@ def from_element(self, element: Element) -> None: self.append(feature) schemata = element.findall(f"{self.ns}Schema") for schema in schemata: - s = Schema(self.ns, id="default") - s.from_element(schema) + s = Schema.class_from_element(ns=self.ns, element=schema, strict=strict) self.append_schema(s) def etree_element( @@ -1615,6 +1650,7 @@ def get_style_by_url(self, style_url: str) -> Union[Style, StyleMap]: for style in self.styles(): if style.id == id: return style + return None class Folder(_Container): @@ -1745,7 +1781,6 @@ def from_element(self, element: Element, strict=False) -> None: return logger.warning("No geometries found") logger.debug("Problem with element: %", config.etree.tostring(element)) - # raise ValueError('No geometries found') def etree_element( self, @@ -1761,13 +1796,14 @@ def etree_element( class KML: - """represents a KML File""" + """represents a KML File.""" _features = [] ns = None def __init__(self, ns: Optional[str] = None) -> None: - """The namespace (ns) may be empty ('') if the 'kml:' prefix is + """ + The namespace (ns) may be empty ('') if the 'kml:' prefix is undesired. Note that all child elements like Document or Placemark need to be initialized with empty namespace as well in this case. @@ -1777,10 +1813,11 @@ def __init__(self, ns: Optional[str] = None) -> None: self.ns = config.KMLNS if ns is None else ns def from_string(self, xml_string: str) -> None: - """create a KML object from a xml string""" + """Create a KML object from a xml string.""" try: element = config.etree.fromstring( - xml_string, parser=config.etree.XMLParser(huge_tree=True, recover=True) + xml_string, + parser=config.etree.XMLParser(huge_tree=True, recover=True), ) except TypeError: element = config.etree.XML(xml_string) @@ -1829,7 +1866,8 @@ def etree_element( else: try: root = config.etree.Element( - f"{self.ns}kml", nsmap={None: self.ns[1:-1]} + f"{self.ns}kml", + nsmap={None: self.ns[1:-1]}, ) except TypeError: root = config.etree.Element(f"{self.ns}kml") @@ -1838,7 +1876,7 @@ def etree_element( return root def to_string(self, prettyprint: bool = False) -> str: - """Return the KML Object as serialized xml""" + """Return the KML Object as serialized xml.""" try: return config.etree.tostring( self.etree_element(), @@ -1847,29 +1885,32 @@ def to_string(self, prettyprint: bool = False) -> str: ).decode("UTF-8") except TypeError: return config.etree.tostring(self.etree_element(), encoding="UTF-8").decode( - "UTF-8" + "UTF-8", ) def features(self) -> Iterator[Union[Folder, Document, Placemark]]: - """iterate over features""" + """Iterate over features.""" for feature in self._features: if isinstance(feature, (Document, Folder, Placemark, _Overlay)): yield feature else: - raise TypeError( + msg = ( "Features must be instances of " "(Document, Folder, Placemark, Overlay)" ) + raise TypeError(msg) def append(self, kmlobj: Union[Folder, Document, Placemark]) -> None: - """append a feature""" + """Append a feature.""" if id(kmlobj) == id(self): - raise ValueError("Cannot append self") + msg = "Cannot append self" + raise ValueError(msg) if isinstance(kmlobj, (Document, Folder, Placemark, _Overlay)): self._features.append(kmlobj) else: + msg = "Features must be instances of (Document, Folder, Placemark, Overlay)" raise TypeError( - "Features must be instances of (Document, Folder, Placemark, Overlay)" + msg, ) diff --git a/fastkml/mixins.py b/fastkml/mixins.py index 60b56a12..cfc6adc7 100644 --- a/fastkml/mixins.py +++ b/fastkml/mixins.py @@ -30,7 +30,7 @@ class TimeMixin: @property def time_stamp(self) -> Optional[KmlDateTime]: - """This just returns the datetime portion of the timestamp""" + """This just returns the datetime portion of the timestamp.""" return self._timestamp.timestamp if self._timestamp is not None else None @time_stamp.setter diff --git a/fastkml/styles.py b/fastkml/styles.py index 48240c65..5ac99055 100644 --- a/fastkml/styles.py +++ b/fastkml/styles.py @@ -21,28 +21,43 @@ """ import logging +from dataclasses import dataclass +from typing import Any +from typing import Dict from typing import Iterable from typing import Iterator from typing import List from typing import Optional -from typing import Type from typing import Union - -from typing_extensions import TypedDict +from typing import cast from fastkml import config from fastkml.base import _BaseObject +from fastkml.enums import ColorMode +from fastkml.enums import DisplayMode +from fastkml.enums import Units from fastkml.enums import Verbosity from fastkml.types import Element logger = logging.getLogger(__name__) +def strtobool(val: str) -> int: + val = val.lower() + if val == "false": + return 0 + if val == "true": + return 1 + return int(float(val)) + + class StyleUrl(_BaseObject): """ - URL of a