diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..02a95511 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +# Set update schedule for GitHub Actions +--- +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every weekday + interval: "daily" +... diff --git a/.github/workflows/codesee-arch-diagram.yml b/.github/workflows/codesee-arch-diagram.yml new file mode 100644 index 00000000..7af0e1ca --- /dev/null +++ b/.github/workflows/codesee-arch-diagram.yml @@ -0,0 +1,81 @@ +on: + push: + branches: + - main + pull_request_target: + types: [opened, synchronize, reopened] + +name: CodeSee Map + +jobs: + test_map_action: + runs-on: ubuntu-latest + continue-on-error: true + name: Run CodeSee Map Analysis + steps: + - name: checkout + id: checkout + uses: actions/checkout@v3.1.0 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + + # codesee-detect-languages has an output with id languages. + - name: Detect Languages + id: detect-languages + uses: Codesee-io/codesee-detect-languages-action@latest + + - name: Configure JDK 16 + uses: actions/setup-java@v3 + if: ${{ fromJSON(steps.detect-languages.outputs.languages).java }} + with: + java-version: '16' + distribution: 'zulu' + + # CodeSee Maps Go support uses a static binary so there's no setup step required. + + - name: Configure Node.js 14 + uses: actions/setup-node@v3 + if: ${{ fromJSON(steps.detect-languages.outputs.languages).javascript }} + with: + node-version: '14' + + - name: Configure Python 3.x + uses: actions/setup-python@v4 + if: ${{ fromJSON(steps.detect-languages.outputs.languages).python }} + with: + python-version: '3.10' + architecture: 'x64' + + - name: Configure Ruby '3.x' + uses: ruby/setup-ruby@v1 + if: ${{ fromJSON(steps.detect-languages.outputs.languages).ruby }} + with: + ruby-version: '3.0' + + # CodeSee Maps Rust support uses a static binary so there's no setup step required. + + - name: Generate Map + id: generate-map + uses: Codesee-io/codesee-map-action@latest + with: + step: map + github_ref: ${{ github.ref }} + languages: ${{ steps.detect-languages.outputs.languages }} + + - name: Upload Map + id: upload-map + uses: Codesee-io/codesee-map-action@latest + with: + step: mapUpload + api_token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }} + github_ref: ${{ github.ref }} + + - name: Insights + id: insights + uses: Codesee-io/codesee-map-action@latest + with: + step: insights + api_token: ${{ secrets.CODESEE_ARCH_DIAG_API_TOKEN }} + github_ref: ${{ github.ref }} diff --git a/.github/workflows/run-all-tests.yml b/.github/workflows/run-all-tests.yml index cf16a65f..8b1fb285 100644 --- a/.github/workflows/run-all-tests.yml +++ b/.github/workflows/run-all-tests.yml @@ -9,12 +9,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.6', '3.7', '3.8', '3.9', '3.10'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11-dev'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3.1.0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -23,31 +23,32 @@ jobs: pip install -r test-requirements.txt - name: Test with pytest run: | - pytest fastkml --cov=fastkml --cov-fail-under=96 --cov-report=xml - - name: "Upload coverage to Codecov" - uses: codecov/codecov-action@v2 - with: - fail_ci_if_error: true + pytest fastkml cpython-lxml: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.6', '3.7', '3.8', '3.9', '3.10'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11-dev'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3.1.0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip wheel + pip install -r test-requirements.txt pip install lxml - name: Test with pytest run: | - python setup.py test + pytest fastkml --cov=fastkml --cov-fail-under=88 --cov-report=xml + - name: "Upload coverage to Codecov" + uses: codecov/codecov-action@v3 + with: + fail_ci_if_error: true static-tests: runs-on: ubuntu-latest @@ -56,18 +57,18 @@ jobs: python-version: ['3.9'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3.1.0 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip wheel pip install -r test-requirements.txt - # - name: Typecheck - # run: | - # mypy fastkml + - name: Typecheck + run: | + mypy fastkml - name: Linting run: | flake8 fastkml examples docs @@ -83,11 +84,11 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - pypy-version: ['pypy-3.6', 'pypy-3.7'] + pypy-version: ['pypy-3.7', 'pypy-3.8', 'pypy-3.9'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3.1.0 - name: Set up Python ${{ matrix.pypy-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.pypy-version }} - name: Install dependencies @@ -103,9 +104,9 @@ jobs: name: Build and publish to PyPI and TestPyPI runs-on: ubuntu-latest steps: - - uses: actions/checkout@master + - uses: actions/checkout@v3.1.0 - name: Set up Python 3.9 - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: 3.9 - name: Install pypa/build diff --git a/.gitignore b/.gitignore index 0544fe37..45d354fa 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,11 @@ venv # editors .vscode/ .idea/ + +# typing +.mypy_cache/ +.pyre/ +.watchmanconfig + +# misc +.dccache diff --git a/.pep8speaks.yml b/.pep8speaks.yml index 4ba8d275..5b0ea0c8 100644 --- a/.pep8speaks.yml +++ b/.pep8speaks.yml @@ -1,5 +1,7 @@ +--- scanner: linter: flake8 flake8: max-line-length: 89 +... diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..7c49f3af --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,60 @@ +--- +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: check-added-large-files + - id: check-docstring-first + - id: check-json + - id: check-merge-conflict + - id: check-toml + - id: check-vcs-permalinks + - id: check-xml + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: mixed-line-ending + - id: name-tests-test + exclude: ^fastkml/tests/base.py + - id: no-commit-to-branch + - id: pretty-format-json + - id: requirements-txt-fixer + - id: trailing-whitespace + - repo: https://github.com/ikamensh/flynt/ + rev: "0.76" + hooks: + - id: flynt + - repo: https://github.com/MarcoGorelli/absolufy-imports + rev: v0.3.1 + hooks: + - id: absolufy-imports + - repo: https://github.com/hakancelikdev/unimport + rev: 0.12.1 + hooks: + - id: unimport + args: [--remove, --include-star-import, --ignore-init, --gitignore] + - repo: https://github.com/psf/black + rev: 22.10.0 + hooks: + - id: black + - repo: https://github.com/PyCQA/flake8 + rev: 5.0.4 + hooks: + - id: flake8 + - repo: https://github.com/pycqa/isort + rev: 5.10.1 + hooks: + - id: isort + - repo: https://github.com/mgedmin/check-manifest + rev: "0.48" + hooks: + - id: check-manifest + # - repo: https://github.com/Lucas-C/pre-commit-hooks-markup + # rev: v1.0.1 + # hooks: + # - id: rst-linter + # - repo: https://github.com/pre-commit/mirrors-mypy + # rev: v0.910 + # hooks: + # - id: mypy +... diff --git a/.pyup.yml b/.pyup.yml index b2eaf00a..b6b6e03d 100644 --- a/.pyup.yml +++ b/.pyup.yml @@ -1,3 +1,4 @@ +--- # configure updates globally # default: all # allowed: all, insecure, False @@ -6,7 +7,7 @@ update: all # configure dependency pinning globally # default: True # allowed: True, False -pin: False +pin: false # set the default branch # default: empty, the default branch on GitHub @@ -20,15 +21,16 @@ schedule: "every day" # search for requirement files # default: True # allowed: True, False -search: True +search: true # Specify requirement files by hand, default is empty # default: empty # allowed: list requirements: - test-requirements.txt: - pin: False + pin: false # allow to close stale PRs # default: True -close_prs: True +close_prs: true +... diff --git a/MANIFEST.in b/MANIFEST.in index 627f9190..d8f2afb5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,3 +3,7 @@ recursive-include docs *.txt recursive-exclude *.pyc *.pyo include docs/LICENSE.GPL exclude fastkml/.* +include *.txt +recursive-include docs *.py +recursive-include docs *.rst +recursive-include docs Makefile diff --git a/docs/Configuration.rst b/docs/Configuration.rst new file mode 100644 index 00000000..583211ea --- /dev/null +++ b/docs/Configuration.rst @@ -0,0 +1,38 @@ +Configuration +============== + +ElementTree configuration +-------------------------- + +By default, fastkml uses the standard libraries +``xml.etree.ElementTree`` or, if installed, ``lxml.etree`` +as its parser, but you can change this by setting the +``fastkml.config.etree`` module variable to a different +implementation. + +E.g. if you have lxml installed, but you want to use the +standard ``xml.etree.ElementTree``, you can do this:: + + >>> import fastkml.config + >>> import xml.etree.ElementTree + >>> fastkml.config.set_etree_implementation(xml.etree.ElementTree) + >>> fastkml.config.set_default_namespaces() + +You can pass any module that implements the ``ElementTree`` interface +to the ``set_etree_implementation`` function. + +Registering additional namespaces +---------------------------------- +The ``fastkml.config.set_default_namespaces`` function registers +the ``kml``, ``gx`` and ``atom`` namespaces with the ``ElementTree``. +You can add any other namespaces you want to use by calling +``fastkml.config.register_namespace`` with the namespace prefix and +the namespace URI. + +.. code-block:: python + + >>> import fastkml.config + >>> import xml.etree.ElementTree + >>> fastkml.config.set_etree_implementation(xml.etree.ElementTree) + >>> fastkml.config.register_namespace(foo='http://foo.com') + >>> config.set_default_namespaces() diff --git a/docs/HISTORY.txt b/docs/HISTORY.txt index 828027c6..5dd3b303 100644 --- a/docs/HISTORY.txt +++ b/docs/HISTORY.txt @@ -7,6 +7,8 @@ Changelog - Drop Python 2 support - Use pygeoif >=1.0 - Drop shapely native support +- Add type annotations +- refactor 0.12 (2020/09/23) diff --git a/docs/LICENSE.GPL b/docs/LICENSE.GPL index aae716b3..232af85a 100644 --- a/docs/LICENSE.GPL +++ b/docs/LICENSE.GPL @@ -55,7 +55,7 @@ modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - + Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a @@ -111,7 +111,7 @@ modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - + GNU LESSER GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION @@ -158,7 +158,7 @@ Library. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - + 2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 @@ -216,7 +216,7 @@ instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - + Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. @@ -267,7 +267,7 @@ Library will still fall under Section 6.) distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - + 6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work @@ -329,7 +329,7 @@ restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - + 7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined @@ -370,7 +370,7 @@ subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - + 11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or @@ -422,7 +422,7 @@ conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - + 14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is @@ -456,7 +456,7 @@ SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS - + How to Apply These Terms to Your New Libraries If you develop a new library, and you want it to be of the greatest @@ -500,5 +500,3 @@ necessary. Here is a sample; alter the names: Ty Coon, President of Vice That's all there is to it! - - diff --git a/docs/contributing.rst b/docs/contributing.rst index 722834f4..a2ee904e 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -63,3 +63,15 @@ signifying that the code is working as expected on all configurations available. .. _tox: https://pypi.python.org/pypi/tox + +pre-commit +~~~~~~~~~~~ + +Install the ``pre-commit`` hook with:: + + pip install pre-commit + pre-commit install + +and check the code with:: + + pre-commit run --all-files diff --git a/docs/usage_guide.rst b/docs/usage_guide.rst index caa9de21..a1203ad2 100644 --- a/docs/usage_guide.rst +++ b/docs/usage_guide.rst @@ -94,7 +94,7 @@ Example how to build a simple KML file from the Python interpreter. Read a KML File/String ---------------- +---------------------- You can create a KML object by reading a KML file as a string @@ -102,13 +102,13 @@ You can create a KML object by reading a KML file as a string # Start by importing the kml module >>> from fastkml import kml - + #Read file into string and convert to UTF-8 (Python3 style) >>> with open(kml_file, 'rt', encoding="utf-8") as myfile: ... doc=myfile.read() - + # OR - + # Setup the string which contains the KML file we want to read >>> doc = """ ... diff --git a/examples/KML_Samples.kml b/examples/KML_Samples.kml index 47ae59a4..3bb729f3 100644 --- a/examples/KML_Samples.kml +++ b/examples/KML_Samples.kml @@ -229,36 +229,36 @@ Placemark descriptions can be enriched by using many standard HTML tags.
For example:
Styles:
-Italics, -Bold, -Underlined, -Strike Out, -subscriptsubscript, -superscriptsuperscript, -Big, -Small, -Typewriter, -Emphasized, -Strong, +Italics, +Bold, +Underlined, +Strike Out, +subscriptsubscript, +superscriptsuperscript, +Big, +Small, +Typewriter, +Emphasized, +Strong, Code
-Fonts:
-red by name, +Fonts:
+red by name, leaf green by hexadecimal RGB
-size 1, -size 2, -size 3, -size 4, -size 5, -size 6, +size 1, +size 2, +size 3, +size 4, +size 5, +size 6, size 7
-Times, -Verdana, +Times, +Verdana, Arial

-Links: +Links:
Google Earth!
diff --git a/examples/UsageExamples.py b/examples/UsageExamples.py index bd1d20fb..fa2221c2 100644 --- a/examples/UsageExamples.py +++ b/examples/UsageExamples.py @@ -18,7 +18,7 @@ def print_child_features(element): k = kml.KML() - with open(fname) as kmlFile: - k.from_string(kmlFile.read().encode("utf-8")) + with open(fname) 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 8c3916d8..ca997c73 100644 --- a/fastkml/__init__.py +++ b/fastkml/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012 Christian Ledermann +# Copyright (C) 2012 -2022 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 @@ -27,27 +27,28 @@ from pkg_resources import DistributionNotFound from pkg_resources import get_distribution -from .atom import Author -from .atom import Contributor -from .atom import Link -from .kml import KML -from .kml import Data -from .kml import Document -from .kml import ExtendedData -from .kml import Folder -from .kml import Placemark -from .kml import Schema -from .kml import SchemaData -from .kml import TimeSpan -from .kml import TimeStamp -from .styles import BalloonStyle -from .styles import IconStyle -from .styles import LabelStyle -from .styles import LineStyle -from .styles import PolyStyle -from .styles import Style -from .styles import StyleMap -from .styles import StyleUrl +from fastkml.atom import Author +from fastkml.atom import Contributor +from fastkml.atom import Link +from fastkml.data import Data +from fastkml.data import ExtendedData +from fastkml.data import Schema +from fastkml.data import SchemaData +from fastkml.gx import GxGeometry +from fastkml.kml import KML +from fastkml.kml import Document +from fastkml.kml import Folder +from fastkml.kml import Placemark +from fastkml.styles import BalloonStyle +from fastkml.styles import IconStyle +from fastkml.styles import LabelStyle +from fastkml.styles import LineStyle +from fastkml.styles import PolyStyle +from fastkml.styles import Style +from fastkml.styles import StyleMap +from fastkml.styles import StyleUrl +from fastkml.times import TimeSpan +from fastkml.times import TimeStamp try: __version__ = get_distribution("fastkml").version @@ -63,6 +64,7 @@ "TimeStamp", "ExtendedData", "Data", + "GxGeometry", "Schema", "SchemaData", "StyleUrl", diff --git a/fastkml/atom.py b/fastkml/atom.py index b988b7fb..9ae52ce3 100644 --- a/fastkml/atom.py +++ b/fastkml/atom.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012 Christian Ledermann +# Copyright (C) 2012 - 2021 Christian Ledermann # # This library is free software; you can redistribute it and/or modify it under # the terms of the GNU Lesser General Public License as published by the Free @@ -33,17 +33,30 @@ import logging import re - -from .config import ATOMNS as NS -from .config import LXML -from .config import etree +from typing import Optional +from typing import Tuple + +from fastkml.base import _XMLObject +from fastkml.config import ATOMNS as NS +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-Z]{2,4}$" -check_email = re.compile(regex).match +regex = r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)" +email_match = re.compile(regex).match + +def check_email(email: str) -> bool: + """Check if the email address is valid.""" + return bool(email_match(email)) -class Link: + +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 @@ -53,8 +66,58 @@ class Link: title, and length. """ - __name__ = "Link" - ns = None + __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 is the URI of the referenced resource @@ -85,15 +148,15 @@ class Link: def __init__( self, - ns=None, - href=None, - rel=None, - type=None, - hreflang=None, - title=None, - length=None, - ): - self.ns = NS if ns is None else ns + ns: Optional[str] = None, + href: Optional[str] = None, + rel: Optional[str] = None, + type: Optional[str] = None, + hreflang: Optional[str] = None, + title: Optional[str] = None, + length: Optional[int] = None, + ) -> None: + self.ns: str = NS if ns is None else ns self.href = href self.rel = rel self.type = type @@ -101,140 +164,94 @@ def __init__( self.title = title self.length = length - def from_string(self, xml_string): - self.from_element(etree.XML(xml_string)) - - def from_element(self, element): - if self.ns + self.__name__.lower() != element.tag: - raise TypeError - if element.get("href"): - self.href = element.get("href") - else: - logger.critical("required attribute href missing") - raise TypeError - if element.get("rel"): - self.rel = element.get("rel") - if element.get("type"): - self.type = element.get("type") - if element.get("hreflang"): - self.hreflang = element.get("hreflang") - if element.get("title"): - self.title = element.get("title") - if element.get("length"): - self.length = element.get("length") - - def etree_element(self): - element = etree.Element(self.ns + self.__name__.lower()) - if self.href: - element.set("href", self.href) - else: - raise ValueError("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", self.length) - return element - - def to_string(self, prettyprint=True): - """Return the ATOM Object as serialized xml""" - if LXML and prettyprint: - return etree.tostring( - self.etree_element(), encoding="utf-8", pretty_print=True - ).decode("UTF-8") - else: - return etree.tostring(self.etree_element(), encoding="utf-8").decode( - "UTF-8" - ) - - -class _Person: + def from_element(self, element: Element) -> None: + super().from_element(element) + + def etree_element(self) -> Element: + return super().etree_element() + + +class _Person(_XMLObject): """ and describe a person, corporation, or similar entity. It has one required element, name, and two optional elements: uri, email. """ - __name__ = None - ns = None - - name = None + __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 # conveys a human-readable name for the person. - uri = None + uri: Optional[str] = None # contains a home page for the person. - email = None + email: Optional[str] = None # contains an email address for the person. - def __init__(self, ns=None, name=None, uri=None, email=None): - self.ns = NS if ns is None else ns + def __init__( + self, + ns: Optional[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 self.name = name self.uri = uri self.email = email - def etree_element(self): - element = etree.Element(self.ns + self.__name__.lower()) - if self.name: - name = etree.SubElement(element, f"{self.ns}name") - name.text = self.name - # else: - # logger.critical('No Name for person defined') - # raise TypeError - if self.uri: - # XXX validate uri - uri = etree.SubElement(element, f"{self.ns}uri") - uri.text = self.uri - if self.email and check_email(self.email): - email = etree.SubElement(element, f"{self.ns}email") - email.text = self.email - return element - - def from_string(self, xml_string): - self.from_element(etree.XML(xml_string)) - - def from_element(self, element): - if self.ns + self.__name__.lower() != element.tag: - raise TypeError - name = element.find(f"{self.ns}name") - if name is not None: - self.name = name.text - uri = element.find(f"{self.ns}uri") - if uri is not None: - self.uri = uri.text - email = element.find(f"{self.ns}email") - if email is not None and check_email(email.text): - self.email = email.text - - def to_string(self, prettyprint=True): - """Return the ATOM Object as serialized xml""" - if LXML and prettyprint: - return etree.tostring( - self.etree_element(), encoding="utf-8", pretty_print=True - ).decode("UTF-8") - else: - return etree.tostring(self.etree_element(), encoding="utf-8").decode( - "UTF-8" - ) + def etree_element(self) -> Element: + return super().etree_element() + + def from_element(self, element: Element) -> None: + super().from_element(element) class Author(_Person): - """Names one author of the feed/entry. A feed/entry may have - multiple authors.""" + """ + Return the names one author of the feed/entry. + + A feed/entry may have multiple authors. + """ - __name__ = "Author" + __name__ = "author" class Contributor(_Person): - """Names one contributor to the feed/entry. A feed/entry may have - multiple contributor elements.""" + """ + Return the names one contributor to the feed/entry. + + A feed/entry may have multiple contributor elements. + """ - __name__ = "Contributor" + __name__ = "contributor" __all__ = ["Author", "Contributor", "Link"] diff --git a/fastkml/base.py b/fastkml/base.py index 28a70d94..7b2defba 100644 --- a/fastkml/base.py +++ b/fastkml/base.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012 Christian Ledermann +# Copyright (C) 2012 - 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 @@ -15,75 +15,125 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Abstract base classes""" +import logging +from typing import Optional +from typing import Tuple +from typing import cast -import fastkml.config as config -from fastkml.config import etree +from fastkml import config +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__) class _XMLObject: - """XML Baseclass""" + """XML Baseclass.""" - __name__ = None - ns = None + __name__ = "" + kml_object_mapping: Tuple[KmlObjectMap, ...] = () - def __init__(self, ns=None): - self.ns = config.KMLNS if ns is None else ns + def __init__(self, ns: Optional[str] = None) -> None: + """Initialize the XML Object.""" + self.ns: str = config.KMLNS if ns is None else ns - def etree_element(self): + def etree_element(self) -> Element: + """Return the KML Object as an Element.""" if self.__name__: - element = etree.Element(self.ns + self.__name__) + element: Element = config.etree.Element( # type: ignore[attr-defined] + f"{self.ns}{self.__name__}" + ) else: raise NotImplementedError( "Call of abstract base class, subclasses implement this!" ) + for mapping in self.kml_object_mapping: + mapping["to_kml"](self, element, **mapping) return element - def from_element(self, element): - if self.ns + self.__name__ != element.tag: + def from_element(self, element: Element) -> None: + """Load the KML Object from an Element.""" + if f"{self.ns}{self.__name__}" != element.tag: raise TypeError("Call of abstract base class, subclasses implement this!") - - def from_string(self, xml_string): - self.from_element(etree.XML(xml_string)) - - def to_string(self, prettyprint=True): - """Return the KML Object as serialized xml""" - if config.LXML and prettyprint: - return etree.tostring( - self.etree_element(), encoding="utf-8", pretty_print=True - ).decode("UTF-8") - else: - return etree.tostring(self.etree_element(), encoding="utf-8").decode( - "UTF-8" + for mapping in self.kml_object_mapping: + mapping["from_kml"](self, element, **mapping) + + def from_string(self, xml_string: str) -> None: + """Load the KML Object from serialized xml.""" + self.from_element( + cast(Element, config.etree.XML(xml_string)) # type: ignore[attr-defined] + ) + + def to_string(self, prettyprint: bool = True) -> str: + """Return the KML Object as serialized xml.""" + try: + return cast( + str, + config.etree.tostring( # type: ignore[attr-defined] + self.etree_element(), + encoding="UTF-8", + pretty_print=prettyprint, + ).decode("UTF-8"), + ) + except TypeError: + return cast( + str, + config.etree.tostring( # type: ignore[attr-defined] + self.etree_element(), encoding="UTF-8" + ).decode("UTF-8"), ) class _BaseObject(_XMLObject): - """This is an abstract base class and cannot be used directly in a + """ + Base class for all KML objects. + + This is an abstract base class and cannot be used directly in a KML file. It provides the id attribute, which allows unique identification of a KML element, and the targetId attribute, which is used to reference objects that have already been loaded into Google Earth. The id attribute must be assigned if the - mechanism is to be used.""" + mechanism is to be used. + """ id = None - targetId = None - - def __init__(self, ns=None, 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, + id: Optional[str] = None, + target_id: Optional[str] = None, + ) -> None: + """Initialize the KML Object.""" super().__init__(ns) self.id = id - self.ns = config.KMLNS if ns is None else ns - - def etree_element(self): - element = super().etree_element() - if self.id: - element.set("id", self.id) - if self.targetId: - element.set("targetId", self.targetId) - return element + self.target_id = target_id + + def etree_element(self) -> Element: + """Return the KML Object as an Element.""" + return super().etree_element() - def from_element(self, 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.targetId = element.get("targetId") diff --git a/fastkml/config.py b/fastkml/config.py index 94a8aca5..f7970c13 100644 --- a/fastkml/config.py +++ b/fastkml/config.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012 Christian Ledermann +# Copyright (C) 2012 - 2021 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 @@ -15,32 +15,63 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Frequently used constants and configuration options""" - import logging import warnings +from types import ModuleType + +__all__ = [ + "ATOMNS", + "DEFAULT_NAME_SPACES", + "FORCE3D", + "GXNS", + "KMLNS", + "register_namespaces", + "set_default_namespaces", + "set_etree_implementation", +] -try: +try: # pragma: no cover from lxml import etree - LXML = True -except ImportError: +except ImportError: # pragma: no cover warnings.warn("Package `lxml` missing. Pretty print will be disabled") - import xml.etree.ElementTree as etree + import xml.etree.ElementTree as etree # type: ignore[no-redef] # noqa: N813 - LXML = False logger = logging.getLogger(__name__) +def set_etree_implementation(implementation: ModuleType) -> None: + """Set the etree implementation to use.""" + global etree + 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 -if hasattr(etree, "register_namespace"): - etree.register_namespace("kml", KMLNS[1:-1]) - etree.register_namespace("atom", ATOMNS[1:-1]) - etree.register_namespace("gx", GXNS[1:-1]) +DEFAULT_NAME_SPACES = { + "kml": KMLNS[1:-1], + "atom": ATOMNS[1:-1], + "gx": GXNS[1:-1], +} -FORCE3D = False -__all__ = ["ATOMNS", "FORCE3D", "GXNS", "KMLNS", "LXML"] +def register_namespaces(**namespaces: str) -> None: + """Register namespaces for use in etree.ElementTree.parse().""" + try: + for prefix, uri in namespaces.items(): + etree.register_namespace(prefix, uri) + except AttributeError: # pragma: no cover + logger.warning("Namespaces were not registered.") + + +def set_default_namespaces() -> None: + """Register the default namespaces for use in etree.ElementTree.parse().""" + register_namespaces(**DEFAULT_NAME_SPACES) + + +set_default_namespaces() + +FORCE3D = False diff --git a/fastkml/data.py b/fastkml/data.py new file mode 100644 index 00000000..4e29c3ef --- /dev/null +++ b/fastkml/data.py @@ -0,0 +1,292 @@ +from typing import List +from typing import Optional +from typing import Tuple +from typing import Union + +from typing_extensions import TypedDict + +import fastkml.config as config +from fastkml.base import _BaseObject +from fastkml.base import _XMLObject +from fastkml.types import Element + + +class SimpleField(TypedDict): + name: str + type: str + displayName: str # noqa: N815 + + +class Schema(_BaseObject): + """ + 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 . + """ + + __name__ = "Schema" + + _simple_fields = None + # The declaration of the custom fields, each of which must specify both the + # type and the name of this field. If either the type or the name is + # omitted, the field is ignored. + name = None + + def __init__( + self, + ns: Optional[str] = None, + id: Optional[str] = None, + target_id: Optional[str] = None, + name: Optional[str] = None, + fields: None = None, + ) -> None: + if id is None: + raise ValueError("Id is required for schema") + super().__init__(ns=ns, id=id, target_id=target_id) + self.simple_fields = fields + self.name = name + + @property + def simple_fields(self) -> Tuple[SimpleField, ...]: + return tuple( + { + "type": simple_field["type"], + "name": simple_field["name"], + "displayName": simple_field.get("displayName"), + } + for simple_field in self._simple_fields + if simple_field.get("type") and simple_field.get("name") + ) + + @simple_fields.setter + def simple_fields(self, fields): + self._simple_fields = [] + 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: + raise TypeError( + f"{name} has the type {type} which is invalid. " + "The type must be one of " + "'string', 'int', 'uint', 'short', " + "'ushort', 'float', 'double', 'bool'" + ) + self._simple_fields.append( + {"type": type, "name": name, "displayName": display_name} + ) + + def from_element(self, element: Element) -> None: + super().from_element(element) + self.name = element.get("name") + simple_fields = element.findall(f"{self.ns}SimpleField") + self.simple_fields = None + 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 etree_element(self) -> Element: + element = super().etree_element() + if self.name: + element.set("name", self.name) + for simple_field in self.simple_fields: + sf = config.etree.SubElement(element, f"{self.ns}SimpleField") + sf.set("type", simple_field["type"]) + sf.set("name", simple_field["name"]) + if simple_field.get("displayName"): + dn = config.etree.SubElement(sf, f"{self.ns}displayName") + dn.text = simple_field["displayName"] + return element + + +class Data(_XMLObject): + """Represents an untyped name/value pair with optional display name.""" + + __name__ = "Data" + + def __init__( + self, + ns: Optional[str] = None, + name: Optional[str] = None, + value: Optional[str] = None, + display_name: Optional[str] = None, + ) -> None: + super().__init__(ns) + + self.name = name + self.value = value + self.display_name = display_name + + def etree_element(self) -> Element: + element = super().etree_element() + element.set("name", self.name) + value = config.etree.SubElement(element, f"{self.ns}value") + value.text = self.value + if self.display_name: + display_name = config.etree.SubElement(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") + 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 + + """ + + __name__ = "ExtendedData" + + def __init__( + self, ns: Optional[str] = None, elements: Optional[List[Data]] = None + ) -> None: + super().__init__(ns) + self.elements = elements or [] + + def etree_element(self) -> Element: + element = super().etree_element() + 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(self.ns) + el.from_element(ud) + self.elements.append(el) + typed_data = element.findall(f"{self.ns}SchemaData") + for sd in typed_data: + el = SchemaData(self.ns, "dummy") + el.from_element(sd) + self.elements.append(el) + + +class SchemaData(_XMLObject): + """ + + This element is used in conjunction with to add typed + custom data to a KML Feature. The Schema element (identified by the + schemaUrl attribute) declares the custom data type. The actual data + objects ("instances" of the custom data) are defined using the + SchemaData element. + The can be a full URL, a reference to a Schema ID defined + in an external KML file, or a reference to a Schema ID defined + in the same KML file. + """ + + __name__ = "SchemaData" + schema_url = None + _data = None + + def __init__( + self, + ns: Optional[str] = None, + schema_url: Optional[str] = None, + data: None = None, + ) -> None: + super().__init__(ns) + if (not isinstance(schema_url, str)) or (not schema_url): + raise ValueError("required parameter schema_url missing") + self.schema_url = schema_url + self._data = [] + self.data = data + + @property + def data(self): + return tuple(self._data) + + @data.setter + def data(self, data): + 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 etree_element(self) -> Element: + element = super().etree_element() + element.set("schemaUrl", self.schema_url) + for data in self.data: + sd = config.etree.SubElement(element, f"{self.ns}SimpleData") + sd.set("name", data["name"]) + sd.text = data["value"] + return element + + def from_element(self, element: Element) -> None: + super().from_element(element) + self.data = [] + 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) diff --git a/fastkml/geometry.py b/fastkml/geometry.py index 91ad193d..3d6f4eec 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -16,8 +16,14 @@ import logging import re - -from pygeoif.factories import shape as asShape +from typing import Any +from typing import List +from typing import Optional +from typing import Sequence +from typing import Union +from typing import cast + +from pygeoif.factories import shape from pygeoif.geometry import GeometryCollection from pygeoif.geometry import LinearRing from pygeoif.geometry import LineString @@ -26,19 +32,22 @@ from pygeoif.geometry import MultiPolygon from pygeoif.geometry import Point from pygeoif.geometry import Polygon +from pygeoif.types import PointType -import fastkml.config as config - -from .base import _BaseObject -from .config import etree +from fastkml import config +from fastkml.base import _BaseObject +from fastkml.types import Element logger = logging.getLogger(__name__) +GeometryType = Union[Polygon, LineString, LinearRing, Point] +MultiGeometryType = Union[MultiPoint, MultiLineString, MultiPolygon, GeometryCollection] +AnyGeometryType = Union[GeometryType, MultiGeometryType] + class Geometry(_BaseObject): """ """ - __name__ = None geometry = None extrude = False tessellate = False @@ -46,13 +55,13 @@ class Geometry(_BaseObject): def __init__( self, - ns=None, - id=None, - geometry=None, - extrude=False, - tessellate=False, - altitude_mode=None, - ): + ns: Optional[str] = None, + id: Optional[str] = None, + geometry: Optional[Any] = None, + extrude: bool = False, + tessellate: bool = False, + altitude_mode: Optional[str] = None, + ) -> None: """ geometry: a geometry that implements the __geo_interface__ convention @@ -117,11 +126,11 @@ def __init__( ): self.geometry = geometry else: - self.geometry = asShape(geometry) + self.geometry = shape(geometry) # write kml - def _set_altitude_mode(self, element): + def _set_altitude_mode(self, element: Element) -> None: if self.altitude_mode: # XXX add 'relativeToSeaFloor', 'clampToSeaFloor', assert self.altitude_mode in [ @@ -130,113 +139,160 @@ def _set_altitude_mode(self, element): "absolute", ] if self.altitude_mode != "clampToGround": - am_element = etree.SubElement(element, f"{self.ns}altitudeMode") + am_element = config.etree.SubElement( # type: ignore[attr-defined] + element, f"{self.ns}altitudeMode" + ) am_element.text = self.altitude_mode - def _set_extrude(self, element): + def _set_extrude(self, element: Element) -> None: if self.extrude and self.altitude_mode in [ "relativeToGround", # 'relativeToSeaFloor', "absolute", ]: - et_element = etree.SubElement(element, f"{self.ns}extrude") + et_element = cast( + Element, + config.etree.SubElement( # type: ignore[attr-defined] + element, f"{self.ns}extrude" + ), + ) et_element.text = "1" - def _etree_coordinates(self, coordinates): - # clampToGround = ( - # (self.altitude_mode == 'clampToGround') - # or (self.altitude_mode is None) - # ) - element = etree.Element(f"{self.ns}coordinates") + def _etree_coordinates( + self, + coordinates: Sequence[PointType], + ) -> Element: + element = cast( + Element, + config.etree.Element(f"{self.ns}coordinates"), # type: ignore[attr-defined] + ) if len(coordinates[0]) == 2: if config.FORCE3D: # and not clampToGround: tuples = (f"{c[0]:f},{c[1]:f},0.000000" for c in coordinates) else: tuples = (f"{c[0]:f},{c[1]:f}" for c in coordinates) elif len(coordinates[0]) == 3: - # if clampToGround: - # if the altitude is ignored anyway, we may as well - # ignore the z-value - # tuples = ('%f,%f' % tuple(c[:2]) for c in coordinates) - # else: - tuples = (f"{c[0]:f},{c[1]:f},{c[2]:f}" for c in coordinates) + tuples = ( + f"{c[0]:f},{c[1]:f},{c[2]:f}" for c in coordinates # type: ignore[misc] + ) else: raise ValueError("Invalid dimensions") element.text = " ".join(tuples) return element - def _etree_point(self, point): + def _etree_point(self, point: Point) -> Element: element = self._extrude_and_altitude_mode("Point") return self._extracted_from__etree_linearring_5(point, element) - def _etree_linestring(self, linestring): + def _etree_linestring(self, linestring: LineString) -> Element: element = self._extrude_and_altitude_mode("LineString") if self.tessellate and self.altitude_mode in [ "clampToGround", "clampToSeaFloor", ]: - ts_element = etree.SubElement(element, f"{self.ns}tessellate") + ts_element = config.etree.SubElement( # type: ignore[attr-defined] + element, f"{self.ns}tessellate" + ) ts_element.text = "1" return self._extracted_from__etree_linearring_5(linestring, element) - def _etree_linearring(self, linearring): + def _etree_linearring(self, linearring: LinearRing) -> Element: element = self._extrude_and_altitude_mode("LinearRing") return self._extracted_from__etree_linearring_5(linearring, element) - def _extracted_from__etree_linearring_5(self, arg0, element): + def _extracted_from__etree_linearring_5( + self, arg0: Union[LineString, LinearRing, Point], element: Element + ) -> Element: coords = list(arg0.coords) element.append(self._etree_coordinates(coords)) return element - def _etree_polygon(self, polygon): + def _etree_polygon(self, polygon: Polygon) -> Element: element = self._extrude_and_altitude_mode("Polygon") - outer_boundary = etree.SubElement(element, f"{self.ns}outerBoundaryIs") + outer_boundary = cast( + Element, + config.etree.SubElement( # type: ignore[attr-defined] + element, + f"{self.ns}outerBoundaryIs", + ), + ) outer_boundary.append(self._etree_linearring(polygon.exterior)) for ib in polygon.interiors: - inner_boundary = etree.SubElement(element, f"{self.ns}innerBoundaryIs") + inner_boundary = cast( + Element, + config.etree.SubElement( # type: ignore[attr-defined] + element, + f"{self.ns}innerBoundaryIs", + ), + ) inner_boundary.append(self._etree_linearring(ib)) return element - def _extrude_and_altitude_mode(self, kml_geometry): - result = etree.Element(f"{self.ns}{kml_geometry}") + def _extrude_and_altitude_mode(self, kml_geometry: str) -> Element: + result = cast( + Element, + config.etree.Element( # type: ignore[attr-defined] + f"{self.ns}{kml_geometry}" + ), + ) self._set_extrude(result) self._set_altitude_mode(result) return result - def _etree_multipoint(self, points): - element = etree.Element(f"{self.ns}MultiGeometry") + def _etree_multipoint(self, points: MultiPoint) -> Element: + element = cast( + Element, + config.etree.Element( # type: ignore[attr-defined] + f"{self.ns}MultiGeometry" + ), + ) for point in points.geoms: element.append(self._etree_point(point)) return element - def _etree_multilinestring(self, linestrings): - element = etree.Element(f"{self.ns}MultiGeometry") + def _etree_multilinestring(self, linestrings: MultiLineString) -> Element: + element = cast( + Element, + config.etree.Element( # type: ignore[attr-defined] + f"{self.ns}MultiGeometry" + ), + ) for linestring in linestrings.geoms: element.append(self._etree_linestring(linestring)) return element - def _etree_multipolygon(self, polygons): - element = etree.Element(f"{self.ns}MultiGeometry") + def _etree_multipolygon(self, polygons: MultiPolygon) -> Element: + element = cast( + Element, + config.etree.Element( # type: ignore[attr-defined] + f"{self.ns}MultiGeometry" + ), + ) for polygon in polygons.geoms: element.append(self._etree_polygon(polygon)) return element - def _etree_collection(self, features): - element = etree.Element(f"{self.ns}MultiGeometry") + def _etree_collection(self, features: GeometryCollection) -> Element: + element = cast( + Element, + config.etree.Element( # type: ignore[attr-defined] + f"{self.ns}MultiGeometry" + ), + ) for feature in features.geoms: if feature.geom_type == "Point": - element.append(self._etree_point(feature)) + element.append(self._etree_point(cast(Point, feature))) elif feature.geom_type == "LinearRing": - element.append(self._etree_linearring(feature)) + element.append(self._etree_linearring(cast(LinearRing, feature))) elif feature.geom_type == "LineString": - element.append(self._etree_linestring(feature)) + element.append(self._etree_linestring(cast(LineString, feature))) elif feature.geom_type == "Polygon": - element.append(self._etree_polygon(feature)) + element.append(self._etree_polygon(cast(Polygon, feature))) else: raise ValueError("Illegal geometry type.") return element - def etree_element(self): + def etree_element(self) -> Element: if isinstance(self.geometry, Point): return self._etree_point(self.geometry) elif isinstance(self.geometry, LinearRing): @@ -258,7 +314,7 @@ def etree_element(self): # read kml - def _get_geometry_spec(self, element): + def _get_geometry_spec(self, element: Element) -> None: extrude = element.find(f"{self.ns}extrude") if extrude is not None: try: @@ -267,7 +323,7 @@ def _get_geometry_spec(self, element): et = False self.extrude = et else: - self.extrude = False + self.extrude = False # type: ignore[unreachable] tessellate = element.find(f"{self.ns}tessellate") if tessellate is not None: try: @@ -276,7 +332,7 @@ def _get_geometry_spec(self, element): te = False self.tessellate = te else: - self.tessellate = False + self.tessellate = False # type: ignore[unreachable] altitude_mode = element.find(f"{self.ns}altitudeMode") if altitude_mode is not None: am = altitude_mode.text.strip() @@ -290,9 +346,9 @@ def _get_geometry_spec(self, element): else: self.altitude_mode = None else: - self.altitude_mode = None + self.altitude_mode = None # type: ignore[unreachable] - def _get_coordinates(self, element): + def _get_coordinates(self, element: Element) -> List[PointType]: coordinates = element.find(f"{self.ns}coordinates") if coordinates is not None: # https://developers.google.com/kml/documentation/kmlreference#coordinates @@ -302,16 +358,20 @@ def _get_coordinates(self, element): # spaces. Clean up badly formatted tuples by stripping # space following commas. latlons = re.sub(r", +", ",", coordinates.text.strip()).split() - return [[float(c) for c in latlon.split(",")] for latlon in latlons] + return [ + cast(PointType, tuple(float(c) for c in latlon.split(","))) + for latlon in latlons + ] - def _get_linear_ring(self, element): + def _get_linear_ring(self, element: Element) -> Optional[LinearRing]: # LinearRing in polygon lr = element.find(f"{self.ns}LinearRing") if lr is not None: coords = self._get_coordinates(lr) return LinearRing(coords) + return None # type: ignore[unreachable] - def _get_geometry(self, element): + def _get_geometry(self, element: Element) -> Optional[GeometryType]: # Point, LineString, # Polygon, LinearRing if element.tag == f"{self.ns}Point": @@ -326,20 +386,23 @@ def _get_geometry(self, element): self._get_geometry_spec(element) outer_boundary = element.find(f"{self.ns}outerBoundaryIs") ob = self._get_linear_ring(outer_boundary) + if not ob: + return None inner_boundaries = element.findall(f"{self.ns}innerBoundaryIs") ibs = [ self._get_linear_ring(inner_boundary) for inner_boundary in inner_boundaries ] - return Polygon.from_linear_rings(ob, *ibs) + return Polygon.from_linear_rings(ob, *[b for b in ibs if b]) if element.tag == f"{self.ns}LinearRing": coords = self._get_coordinates(element) self._get_geometry_spec(element) return LinearRing(coords) + return None - def _get_multigeometry(self, element): + def _get_multigeometry(self, element: Element) -> Optional[MultiGeometryType]: # MultiGeometry - geoms = [] + geoms: List[Union[AnyGeometryType, None]] = [] if element.tag == f"{self.ns}MultiGeometry": points = element.findall(f"{self.ns}Point") for point in points: @@ -354,31 +417,46 @@ def _get_multigeometry(self, element): self._get_geometry_spec(polygon) outer_boundary = polygon.find(f"{self.ns}outerBoundaryIs") ob = self._get_linear_ring(outer_boundary) + if not ob: + continue inner_boundaries = polygon.findall(f"{self.ns}innerBoundaryIs") - ibs = [ + inner_bs = [ self._get_linear_ring(inner_boundary) for inner_boundary in inner_boundaries ] + ibs: List[LinearRing] = [ib for ib in inner_bs if ib] geoms.append(Polygon.from_linear_rings(ob, *ibs)) linearings = element.findall(f"{self.ns}LinearRing") if linearings: for lr in linearings: self._get_geometry_spec(lr) geoms.append(LinearRing(self._get_coordinates(lr))) - if geoms: - geom_types = {geom.geom_type for geom in geoms} + clean_geoms: List[AnyGeometryType] = [g for g in geoms if g] + if clean_geoms: + geom_types = {geom.geom_type for geom in clean_geoms} if len(geom_types) > 1: - return GeometryCollection(geoms) + return GeometryCollection( + clean_geoms, # type: ignore[arg-type] + ) if "Point" in geom_types: - return MultiPoint.from_points(*geoms) + return MultiPoint.from_points( + *clean_geoms, # type: ignore[arg-type] + ) elif "LineString" in geom_types: - return MultiLineString.from_linestrings(*geoms) + return MultiLineString.from_linestrings( + *clean_geoms, # type: ignore[arg-type] + ) elif "Polygon" in geom_types: - return MultiPolygon.from_polygons(*geoms) + return MultiPolygon.from_polygons( + *clean_geoms, # type: ignore[arg-type] + ) elif "LinearRing" in geom_types: - return GeometryCollection(geoms) + return GeometryCollection( + clean_geoms, # type: ignore[arg-type] + ) + return None - def from_element(self, element): + def from_element(self, element: Element) -> None: geom = self._get_geometry(element) if geom is not None: self.geometry = geom diff --git a/fastkml/gx.py b/fastkml/gx.py index b1a710fd..86e42c6c 100644 --- a/fastkml/gx.py +++ b/fastkml/gx.py @@ -1,4 +1,4 @@ -# Copyright (C) 2012 Christian Ledermann +# Copyright (C) 2012 - 2022 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 @@ -77,13 +77,19 @@ """ import logging +from typing import List +from typing import Optional +from typing import Union +from typing import cast from pygeoif.geometry import GeometryCollection from pygeoif.geometry import LineString from pygeoif.geometry import MultiLineString +from pygeoif.types import PointType -from .config import GXNS as NS -from .geometry import Geometry +from fastkml.config import GXNS as NS +from fastkml.geometry import Geometry +from fastkml.types import Element logger = logging.getLogger(__name__) @@ -91,9 +97,9 @@ class GxGeometry(Geometry): def __init__( self, - ns=None, - id=None, - ): + ns: None = None, + id: None = None, + ) -> None: """ gxgeometry: a read-only subclass of geometry supporting gx: features, like gx:Track @@ -101,34 +107,47 @@ def __init__( super().__init__(ns, id) self.ns = NS if ns is None else ns - def _get_geometry(self, element): + def _get_geometry(self, element: Element) -> Optional[LineString]: # Track if element.tag == (f"{self.ns}Track"): coords = self._get_coordinates(element) self._get_geometry_spec(element) - return LineString(coords) + return LineString( + coords, + ) + return None - def _get_multigeometry(self, element): + def _get_multigeometry( + self, + element: Element, + ) -> Union[MultiLineString, GeometryCollection, None]: # MultiTrack geoms = [] if element.tag == (f"{self.ns}MultiTrack"): tracks = element.findall(f"{self.ns}Track") for track in tracks: self._get_geometry_spec(track) - geoms.append(LineString(self._get_coordinates(track))) + geoms.append( + LineString( + self._get_coordinates(track), + ) + ) geom_types = {geom.geom_type for geom in geoms} if len(geom_types) > 1: return GeometryCollection(geoms) if "LineString" in geom_types: return MultiLineString.from_linestrings(*geoms) + return None - def _get_coordinates(self, element): + def _get_coordinates(self, element: Element) -> List[PointType]: coordinates = element.findall(f"{self.ns}coord") if coordinates is not None: return [ - [float(c) for c in coord.text.strip().split()] for coord in coordinates + cast(PointType, tuple(float(c) for c in coord.text.strip().split())) + for coord in coordinates ] + return [] # type: ignore[unreachable] __all__ = ["GxGeometry"] diff --git a/fastkml/helpers.py b/fastkml/helpers.py new file mode 100644 index 00000000..24f6c2a4 --- /dev/null +++ b/fastkml/helpers.py @@ -0,0 +1,149 @@ +# 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: # type: ignore[unreachable] + 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 4621b220..169d92fb 100644 --- a/fastkml/kml.py +++ b/fastkml/kml.py @@ -26,122 +26,33 @@ """ import logging import urllib.parse as urlparse -from datetime import date -from datetime import datetime - -# note that there are some ISO 8601 timeparsers at pypi -# but in my tests all of them had some errors so we rely on the -# tried and tested dateutil here which is more stable. As a side effect -# we can also parse non ISO compliant dateTimes -import dateutil.parser +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 .base import _BaseObject -from .base import _XMLObject -from .config import etree -from .geometry import Geometry -from .styles import Style -from .styles import StyleMap -from .styles import StyleUrl -from .styles import _StyleSelector +from fastkml.base import _BaseObject +from fastkml.data import Data +from fastkml.data import ExtendedData +from fastkml.data import Schema +from fastkml.data import SchemaData +from fastkml.geometry import Geometry +from fastkml.styles import Style +from fastkml.styles import StyleMap +from fastkml.styles import StyleUrl +from fastkml.styles import _StyleSelector +from fastkml.times import TimeSpan +from fastkml.times import TimeStamp +from fastkml.types import Element +from fastkml.views import Camera +from fastkml.views import LookAt logger = logging.getLogger(__name__) -class KML: - """represents a KML File""" - - _features = [] - ns = None - - def __init__(self, ns=None): - """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. - - """ - self._features = [] - - self.ns = config.KMLNS if ns is None else ns - - def from_string(self, xml_string): - """create a KML object from a xml string""" - if config.LXML: - element = etree.fromstring( - xml_string, parser=etree.XMLParser(huge_tree=True, recover=True) - ) - else: - element = etree.XML(xml_string) - - if not element.tag.endswith("kml"): - raise TypeError - - ns = element.tag.rstrip("kml") - documents = element.findall(f"{ns}Document") - for document in documents: - feature = Document(ns) - feature.from_element(document) - self.append(feature) - folders = element.findall(f"{ns}Folder") - for folder in folders: - feature = Folder(ns) - feature.from_element(folder) - self.append(feature) - placemarks = element.findall(f"{ns}Placemark") - for placemark in placemarks: - feature = Placemark(ns) - feature.from_element(placemark) - self.append(feature) - - def etree_element(self): - # self.ns may be empty, which leads to unprefixed kml elements. - # However, in this case the xlmns should still be mentioned on the kml - # element, just without prefix. - if not self.ns: - root = etree.Element(f"{self.ns}kml") - root.set("xmlns", config.KMLNS[1:-1]) - elif config.LXML: - root = etree.Element(f"{self.ns}kml", nsmap={None: self.ns[1:-1]}) - else: - root = etree.Element(f"{self.ns}kml") - for feature in self.features(): - root.append(feature.etree_element()) - return root - - def to_string(self, prettyprint=False): - """Return the KML Object as serialized xml""" - if config.LXML and prettyprint: - return etree.tostring( - self.etree_element(), encoding="utf-8", pretty_print=True - ).decode("UTF-8") - else: - return etree.tostring(self.etree_element(), encoding="utf-8").decode( - "UTF-8" - ) - - def features(self): - """iterate over features""" - for feature in self._features: - if isinstance(feature, (Document, Folder, Placemark)): - yield feature - else: - raise TypeError( - "Features must be instances of " "(Document, Folder, Placemark)" - ) - - def append(self, kmlobj): - """append a feature""" - if isinstance(kmlobj, (Document, Folder, Placemark)): - self._features.append(kmlobj) - else: - raise TypeError( - "Features must be instances of (Document, Folder, Placemark)" - ) - - class _Feature(_BaseObject): """ abstract element; do not create @@ -183,7 +94,7 @@ class _Feature(_BaseObject): # You can use the
tag to specify the location of a point # instead of using latitude and longitude coordinates. - _phoneNumber = None + _phone_number = None # A string value representing a telephone number. # This element is used by Google Maps Mobile only. @@ -204,7 +115,7 @@ class _Feature(_BaseObject): description = None # User-supplied content that appears in the description balloon. - _styleUrl = None + _style_url = None # URL of a