From 3a5cc1e7068ca83641b93536e8cfd11ebcb77adf Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 12 Dec 2022 16:49:24 +0000 Subject: [PATCH 01/25] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/asottile/pyupgrade: v3.3.0 → v3.3.1](https://github.com/asottile/pyupgrade/compare/v3.3.0...v3.3.1) - [github.com/psf/black: 22.10.0 → 22.12.0](https://github.com/psf/black/compare/22.10.0...22.12.0) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0e5df5a7..ceebc6ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,12 +34,12 @@ repos: - id: unimport args: [--remove, --include-star-import, --ignore-init, --gitignore] - repo: https://github.com/asottile/pyupgrade - rev: v3.3.0 + rev: v3.3.1 hooks: - id: pyupgrade args: ["--py3-plus", "--py37-plus"] - repo: https://github.com/psf/black - rev: 22.10.0 + rev: 22.12.0 hooks: - id: black - repo: https://github.com/PyCQA/flake8 From d249cd7c661e91b990e4066863f3b0bc9ed14d2d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Dec 2022 22:11:15 +0000 Subject: [PATCH 02/25] Bump actions/checkout from 3.1.0 to 3.2.0 Bumps [actions/checkout](https://github.com/actions/checkout) from 3.1.0 to 3.2.0. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3.1.0...v3.2.0) --- updated-dependencies: - dependency-name: actions/checkout dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/run-all-tests.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/run-all-tests.yml b/.github/workflows/run-all-tests.yml index bb9cfeb2..fcd4bc7f 100644 --- a/.github/workflows/run-all-tests.yml +++ b/.github/workflows/run-all-tests.yml @@ -12,7 +12,7 @@ jobs: python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12-dev'] steps: - - uses: actions/checkout@v3.1.0 + - uses: actions/checkout@v3.2.0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -32,7 +32,7 @@ jobs: python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] steps: - - uses: actions/checkout@v3.1.0 + - uses: actions/checkout@v3.2.0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -57,7 +57,7 @@ jobs: python-version: ['3.9'] steps: - - uses: actions/checkout@v3.1.0 + - uses: actions/checkout@v3.2.0 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -86,7 +86,7 @@ jobs: matrix: pypy-version: ['pypy-3.7', 'pypy-3.8', 'pypy-3.9'] steps: - - uses: actions/checkout@v3.1.0 + - uses: actions/checkout@v3.2.0 - name: Set up Python ${{ matrix.pypy-version }} uses: actions/setup-python@v4 with: @@ -104,7 +104,7 @@ jobs: name: Build and publish to PyPI and TestPyPI runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3.1.0 + - uses: actions/checkout@v3.2.0 - name: Set up Python 3.9 uses: actions/setup-python@v4 with: From 49767cf332568c8d2901504c67c4a730d25e4396 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Wed, 14 Dec 2022 12:51:22 +0000 Subject: [PATCH 03/25] move tests, test times --- .pre-commit-config.yaml | 2 +- MANIFEST.in | 1 + docs/installing.rst | 4 +- fastkml/data.py | 16 ++ fastkml/kml.py | 2 +- fastkml/times.py | 10 +- fastkml/views.py | 48 +++-- pyproject.toml | 2 +- {fastkml/tests => tests}/__init__.py | 0 {fastkml/tests => tests}/atom_test.py | 4 +- {fastkml/tests => tests}/base.py | 0 {fastkml/tests => tests}/base_test.py | 4 +- {fastkml/tests => tests}/config_test.py | 0 {fastkml/tests => tests}/geometry_test.py | 4 +- {fastkml/tests => tests}/gx_test.py | 4 +- {fastkml/tests => tests}/kml_test.py | 4 +- {fastkml/tests => tests}/oldunit_test.py | 221 ------------------- {fastkml/tests => tests}/styles_test.py | 4 +- tests/times_test.py | 250 ++++++++++++++++++++++ {fastkml/tests => tests}/views_test.py | 4 +- tox.ini | 2 +- 21 files changed, 317 insertions(+), 269 deletions(-) rename {fastkml/tests => tests}/__init__.py (100%) rename {fastkml/tests => tests}/atom_test.py (98%) rename {fastkml/tests => tests}/base.py (100%) rename {fastkml/tests => tests}/base_test.py (97%) rename {fastkml/tests => tests}/config_test.py (100%) rename {fastkml/tests => tests}/geometry_test.py (92%) rename {fastkml/tests => tests}/gx_test.py (92%) rename {fastkml/tests => tests}/kml_test.py (97%) rename {fastkml/tests => tests}/oldunit_test.py (94%) rename {fastkml/tests => tests}/styles_test.py (99%) create mode 100644 tests/times_test.py rename {fastkml/tests => tests}/views_test.py (98%) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ceebc6ff..d5f8efe3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: - id: end-of-file-fixer - id: mixed-line-ending - id: name-tests-test - exclude: ^fastkml/tests/base.py + exclude: ^tests/base.py - id: no-commit-to-branch - id: pretty-format-json - id: requirements-txt-fixer diff --git a/MANIFEST.in b/MANIFEST.in index d8f2afb5..c8ecd40b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,3 +7,4 @@ include *.txt recursive-include docs *.py recursive-include docs *.rst recursive-include docs Makefile +recursive-include tests *.py diff --git a/docs/installing.rst b/docs/installing.rst index bd634a8c..f0738c8d 100644 --- a/docs/installing.rst +++ b/docs/installing.rst @@ -1,7 +1,7 @@ Installation ============ -fastkml works with CPython and Pypy version 3.6+ and is +fastkml works with CPython and Pypy version 3.7+ and is continually tested for these version. Jython and IronPython are not tested but *should* work. @@ -10,7 +10,7 @@ Jython and IronPython are not tested but *should* work. fastkml works on Unix/Linux, OS X, and Windows. -Install it with ``pip install fastkml`` or ``easy_install fastkml``. +Install it with ``pip install fastkml``. If you use fastkml extensively or need to process big KML files, consider installing lxml_ as it speeds up processing. diff --git a/fastkml/data.py b/fastkml/data.py index c96e6312..18ace775 100644 --- a/fastkml/data.py +++ b/fastkml/data.py @@ -1,3 +1,19 @@ +# Copyright (C) 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 +# 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 +"""Add Custom Data""" from typing import Dict from typing import List from typing import Optional diff --git a/fastkml/kml.py b/fastkml/kml.py index 6ef4d66c..a53ecb91 100644 --- a/fastkml/kml.py +++ b/fastkml/kml.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 diff --git a/fastkml/times.py b/fastkml/times.py index f991a4b0..9f8ea886 100644 --- a/fastkml/times.py +++ b/fastkml/times.py @@ -1,8 +1,8 @@ """Date and time handling in KML.""" from datetime import date from datetime import datetime -from typing import List from typing import Optional +from typing import Tuple from typing import Union # note that there are some ISO 8601 timeparsers at pypi @@ -52,7 +52,7 @@ def get_resolution( resolution = None return resolution - def parse_str(self, datestr: str) -> List[Union[datetime, str]]: + def parse_str(self, datestr: str) -> Tuple[datetime, str]: resolution = "dateTime" year = 0 month = 1 @@ -79,7 +79,7 @@ def parse_str(self, datestr: str) -> List[Union[datetime, str]]: dt = dateutil.parser.parse(datestr) else: raise ValueError - return [dt, resolution] + return dt, resolution def date_to_string( self, @@ -107,7 +107,7 @@ class TimeStamp(_TimePrimitive): """Represents a single moment in time.""" __name__ = "TimeStamp" - timestamp = None + timestamp: Optional[Tuple[datetime, str]] = None def __init__( self, @@ -119,7 +119,7 @@ def __init__( ) -> None: super().__init__(ns=ns, id=id, target_id=target_id) resolution = self.get_resolution(timestamp, resolution) - self.timestamp = [timestamp, resolution] + self.timestamp = (timestamp, resolution) def etree_element(self) -> Element: element = super().etree_element() diff --git a/fastkml/views.py b/fastkml/views.py index 9d86bd1b..c393d94b 100644 --- a/fastkml/views.py +++ b/fastkml/views.py @@ -1,3 +1,4 @@ +import datetime import logging from typing import Optional from typing import Union @@ -18,24 +19,24 @@ class _AbstractView(_BaseObject): This element is extended by the and elements. """ - _longitude = None + _longitude: Optional[float] = None # Longitude of the virtual camera (eye point). Angular distance in degrees, # relative to the Prime Meridian. Values west of the Meridian range from # −180 to 0 degrees. Values east of the Meridian range from 0 to 180 degrees. - _latitude = None + _latitude: Optional[float] = None # Latitude of the virtual camera. Degrees north or south of the Equator # (0 degrees). Values range from −90 degrees to 90 degrees. - _altitude = None + _altitude: Optional[float] = None # Distance of the camera from the earth's surface, in meters. Interpreted # according to the Camera's or . - _heading = None + _heading: Optional[float] = None # Direction (azimuth) of the camera, in degrees. Default=0 (true North). # (See diagram.) Values range from 0 to 360 degrees. - _tilt = None + _tilt: Optional[float] = None # Rotation, in degrees, of the camera around the X axis. A value of 0 # indicates that the view is aimed straight down toward the earth (the # most common case). A value for 90 for indicates that the view @@ -43,7 +44,7 @@ class _AbstractView(_BaseObject): # view is pointed up into the sky. Values for are clamped at +180 # degrees. - _altitude_mode = "relativeToGround" + _altitude_mode: str = "relativeToGround" # Specifies how the specified for the Camera is interpreted. # Possible values are as follows: # relativeToGround - @@ -58,8 +59,8 @@ class _AbstractView(_BaseObject): # absolute - # Interprets the as a value in meters above sea level. - _timespan = None - _timestamp = None + _timespan: Optional[TimeSpan] = None + _timestamp: Optional[TimeStamp] = None def __init__( self, @@ -87,19 +88,20 @@ def __init__( self._timestamp = time_primitive @property - def timestamp(self): + def timestamp(self) -> Optional[datetime.datetime]: if self._timestamp is not None: return self._timestamp.timestamp[0] + return None @timestamp.setter - def timestamp(self, dt): + def timestamp(self, dt: datetime.datetime) -> None: self._timestamp = None if dt is None else TimeStamp(timestamp=dt) if self._timestamp is not None: logger.warning("Setting a TimeStamp, TimeSpan deleted") self._timespan = None @property - def begin(self): + def begin(self) -> Optional[datetime.datetime]: if self._timespan is None: return None return self._timespan.begin[0] @@ -117,11 +119,11 @@ def begin(self, dt) -> None: self._timestamp = None @property - def end(self): + def end(self) -> Optional[datetime.datetime]: return None if self._timespan is None else self._timespan.end[0] @end.setter - def end(self, dt): + def end(self, dt) -> None: if self._timespan is None: self._timespan = TimeSpan(end=dt) elif self._timespan.end is None: @@ -202,7 +204,7 @@ def altitude_mode(self) -> str: return self._altitude_mode @altitude_mode.setter - def altitude_mode(self, mode) -> None: + def altitude_mode(self, mode: str) -> None: if mode in ("relativeToGround", "clampToGround", "absolute"): self._altitude_mode = str(mode) else: @@ -211,7 +213,7 @@ def altitude_mode(self, mode) -> None: # "altitude_mode must be one of " "relativeToGround, # clampToGround, absolute") - def from_element(self, element): + def from_element(self, element: Element): super().from_element(element) longitude = element.find(f"{self.ns}longitude") if longitude is not None: @@ -245,7 +247,7 @@ def from_element(self, element): s.from_element(timestamp) self._timestamp = s - def etree_element(self): + def etree_element(self) -> Element: element = super().etree_element() if self.longitude: longitude = config.etree.SubElement(element, f"{self.ns}longitude") @@ -303,7 +305,7 @@ class Camera(_AbstractView): __name__ = "Camera" - _roll = None + _roll: Optional[float] = None # Rotation, in degrees, of the camera around the Z axis. Values range from # −180 to +180 degrees. @@ -335,7 +337,7 @@ def __init__( ) self._roll = roll - def from_element(self, element) -> None: + def from_element(self, element: Element) -> None: super().from_element(element) roll = element.find(f"{self.ns}roll") if roll is not None: @@ -366,7 +368,7 @@ class LookAt(_AbstractView): __name__ = "LookAt" - _range = None + _range: Optional[float] = None # Distance in meters from the point specified by , , # and to the LookAt position. (See diagram below.) @@ -399,11 +401,11 @@ def __init__( self._range = range @property - def range(self): + def range(self) -> Optional[float]: return self._range @range.setter - def range(self, value): + def range(self, value) -> None: if isinstance(value, (str, int, float)): self._range = float(value) elif value is None: @@ -411,13 +413,13 @@ def range(self, value): else: raise ValueError - def from_element(self, element): + def from_element(self, element: Element) -> None: super().from_element(element) range_var = element.find(f"{self.ns}range") if range_var is not None: self.range = range_var.text - def etree_element(self): + def etree_element(self) -> Element: element = super().etree_element() if self.range: range_var = config.etree.SubElement(element, f"{self.ns}range") diff --git a/pyproject.toml b/pyproject.toml index 5897d0fe..8ee0b917 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ show_error_codes = true [[tool.mypy.overrides]] module = [ - "fastkml.kml", "fastkml.views", "fastkml.times", + "fastkml.kml", "fastkml.views", "fastkml.tests.oldunit_test", "fastkml.tests.config_test" ] ignore_errors = true diff --git a/fastkml/tests/__init__.py b/tests/__init__.py similarity index 100% rename from fastkml/tests/__init__.py rename to tests/__init__.py diff --git a/fastkml/tests/atom_test.py b/tests/atom_test.py similarity index 98% rename from fastkml/tests/atom_test.py rename to tests/atom_test.py index 638ad4bf..223cdfee 100644 --- a/fastkml/tests/atom_test.py +++ b/tests/atom_test.py @@ -17,8 +17,8 @@ """Test the Atom classes.""" from fastkml import atom -from fastkml.tests.base import Lxml -from fastkml.tests.base import StdLibrary +from tests.base import Lxml +from tests.base import StdLibrary class TestStdLibrary(StdLibrary): diff --git a/fastkml/tests/base.py b/tests/base.py similarity index 100% rename from fastkml/tests/base.py rename to tests/base.py diff --git a/fastkml/tests/base_test.py b/tests/base_test.py similarity index 97% rename from fastkml/tests/base_test.py rename to tests/base_test.py index 3ed9dd68..5637dc8d 100644 --- a/fastkml/tests/base_test.py +++ b/tests/base_test.py @@ -22,8 +22,8 @@ from fastkml import base from fastkml import config from fastkml import types -from fastkml.tests.base import Lxml -from fastkml.tests.base import StdLibrary +from tests.base import Lxml +from tests.base import StdLibrary class TestStdLibrary(StdLibrary): diff --git a/fastkml/tests/config_test.py b/tests/config_test.py similarity index 100% rename from fastkml/tests/config_test.py rename to tests/config_test.py diff --git a/fastkml/tests/geometry_test.py b/tests/geometry_test.py similarity index 92% rename from fastkml/tests/geometry_test.py rename to tests/geometry_test.py index dd54fe70..25499538 100644 --- a/fastkml/tests/geometry_test.py +++ b/tests/geometry_test.py @@ -15,8 +15,8 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Test the geometry classes.""" -from fastkml.tests.base import Lxml -from fastkml.tests.base import StdLibrary +from tests.base import Lxml +from tests.base import StdLibrary class TestStdLibrary(StdLibrary): diff --git a/fastkml/tests/gx_test.py b/tests/gx_test.py similarity index 92% rename from fastkml/tests/gx_test.py rename to tests/gx_test.py index 2b461d76..66d43167 100644 --- a/fastkml/tests/gx_test.py +++ b/tests/gx_test.py @@ -15,8 +15,8 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Test the gx classes.""" -from fastkml.tests.base import Lxml -from fastkml.tests.base import StdLibrary +from tests.base import Lxml +from tests.base import StdLibrary class TestStdLibrary(StdLibrary): diff --git a/fastkml/tests/kml_test.py b/tests/kml_test.py similarity index 97% rename from fastkml/tests/kml_test.py rename to tests/kml_test.py index f8bd6d96..11720fa1 100644 --- a/fastkml/tests/kml_test.py +++ b/tests/kml_test.py @@ -16,8 +16,8 @@ """Test the kml classes.""" from fastkml import kml -from fastkml.tests.base import Lxml -from fastkml.tests.base import StdLibrary +from tests.base import Lxml +from tests.base import StdLibrary class TestStdLibrary(StdLibrary): diff --git a/fastkml/tests/oldunit_test.py b/tests/oldunit_test.py similarity index 94% rename from fastkml/tests/oldunit_test.py rename to tests/oldunit_test.py index b4d5ef0f..76b7ecbf 100644 --- a/fastkml/tests/oldunit_test.py +++ b/tests/oldunit_test.py @@ -13,12 +13,9 @@ # 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 -import datetime import xml.etree.ElementTree import pytest -from dateutil.tz import tzoffset -from dateutil.tz import tzutc from fastkml import atom from fastkml import base @@ -1460,224 +1457,6 @@ def test_get_style_by_url(self): assert isinstance(style, styles.StyleMap) -class TestDateTime: - def test_timestamp(self): - now = datetime.datetime.now() - ts = kml.TimeStamp(timestamp=now) - assert ts.timestamp == [now, "dateTime"] - assert "TimeStamp>" in str(ts.to_string()) - assert "when>" in str(ts.to_string()) - assert now.isoformat() in str(ts.to_string()) - y2k = datetime.date(2000, 1, 1) - ts = kml.TimeStamp(timestamp=y2k) - assert ts.timestamp == [y2k, "date"] - assert "2000-01-01" in str(ts.to_string()) - - def test_timestamp_resolution(self): - now = datetime.datetime.now() - ts = kml.TimeStamp(timestamp=now) - assert now.isoformat() in str(ts.to_string()) - ts.timestamp[1] = "date" - assert now.date().isoformat() in str(ts.to_string()) - assert now.isoformat() not in str(ts.to_string()) - year = str(now.year) - ym = now.strftime("%Y-%m") - ts.timestamp[1] = "gYearMonth" - assert ym in str(ts.to_string()) - assert now.date().isoformat() not in str(ts.to_string()) - ts.timestamp[1] = "gYear" - assert year in str(ts.to_string()) - assert ym not in str(ts.to_string()) - ts.timestamp = None - pytest.raises(TypeError, ts.to_string) - - def test_timespan(self): - now = datetime.datetime.now() - y2k = datetime.datetime(2000, 1, 1) - ts = kml.TimeSpan(end=now, begin=y2k) - assert ts.end == [now, "dateTime"] - assert ts.begin == [y2k, "dateTime"] - assert "TimeSpan>" in str(ts.to_string()) - assert "begin>" in str(ts.to_string()) - assert "end>" in str(ts.to_string()) - assert now.isoformat() in str(ts.to_string()) - assert y2k.isoformat() in str(ts.to_string()) - ts.end = None - assert now.isoformat() not in str(ts.to_string()) - assert y2k.isoformat() in str(ts.to_string()) - ts.begin = None - pytest.raises(ValueError, ts.to_string) - - def test_feature_timestamp(self): - now = datetime.datetime.now() - f = kml.Document() - f.time_stamp = now - assert f.time_stamp == now - assert now.isoformat() in str(f.to_string()) - assert "TimeStamp>" in str(f.to_string()) - assert "when>" in str(f.to_string()) - f.time_stamp = now.date() - assert now.date().isoformat() in str(f.to_string()) - assert now.isoformat() not in str(f.to_string()) - f.time_stamp = None - assert "TimeStamp>" not in str(f.to_string()) - - def test_feature_timespan(self): - now = datetime.datetime.now() - y2k = datetime.date(2000, 1, 1) - f = kml.Document() - f.begin = y2k - f.end = now - assert f.begin == y2k - assert f.end == now - assert now.isoformat() in str(f.to_string()) - assert "2000-01-01" in str(f.to_string()) - assert "TimeSpan>" in str(f.to_string()) - assert "begin>" in str(f.to_string()) - assert "end>" in str(f.to_string()) - f.end = None - assert now.isoformat() not in str(f.to_string()) - assert "2000-01-01" in str(f.to_string()) - assert "TimeSpan>" in str(f.to_string()) - assert "begin>" in str(f.to_string()) - assert "end>" not in str(f.to_string()) - f.begin = None - assert "TimeSpan>" not in str(f.to_string()) - - def test_feature_timespan_stamp(self): - now = datetime.datetime.now() - y2k = datetime.date(2000, 1, 1) - f = kml.Document() - f.begin = y2k - f.end = now - assert now.isoformat() in str(f.to_string()) - assert "2000-01-01" in str(f.to_string()) - assert "TimeSpan>" in str(f.to_string()) - assert "begin>" in str(f.to_string()) - assert "end>" in str(f.to_string()) - assert "TimeStamp>" not in str(f.to_string()) - assert "when>" not in str(f.to_string()) - # when we set a timestamp an existing timespan will be deleted - f.time_stamp = now - assert now.isoformat() in str(f.to_string()) - assert "TimeStamp>" in str(f.to_string()) - assert "when>" in str(f.to_string()) - assert "2000-01-01" not in str(f.to_string()) - assert "TimeSpan>" not in str(f.to_string()) - assert "begin>" not in str(f.to_string()) - assert "end>" not in str(f.to_string()) - # when we set a timespan an existing timestamp will be deleted - f.end = y2k - assert now.isoformat() not in str(f.to_string()) - assert "2000-01-01" in str(f.to_string()) - assert "TimeSpan>" in str(f.to_string()) - assert "begin>" not in str(f.to_string()) - assert "end>" in str(f.to_string()) - assert "TimeStamp>" not in str(f.to_string()) - assert "when>" not in str(f.to_string()) - # We manipulate our Feature so it has timespan and stamp - ts = kml.TimeStamp(timestamp=now) - f._timestamp = ts - # this raises an exception as only either timespan or timestamp - # are allowed not both - pytest.raises(ValueError, f.to_string) - - def test_read_timestamp(self): - ts = kml.TimeStamp(ns="") - doc = """ - - 1997 - - """ - - ts.from_string(doc) - assert ts.timestamp[1] == "gYear" - assert ts.timestamp[0] == datetime.datetime(1997, 1, 1, 0, 0) - doc = """ - - 1997-07 - - """ - - ts.from_string(doc) - assert ts.timestamp[1] == "gYearMonth" - assert ts.timestamp[0] == datetime.datetime(1997, 7, 1, 0, 0) - doc = """ - - 199808 - - """ - - ts.from_string(doc) - assert ts.timestamp[1] == "gYearMonth" - assert ts.timestamp[0] == datetime.datetime(1998, 8, 1, 0, 0) - doc = """ - - 1997-07-16 - - """ - - ts.from_string(doc) - assert ts.timestamp[1] == "date" - assert ts.timestamp[0] == datetime.datetime(1997, 7, 16, 0, 0) - # dateTime (YYYY-MM-DDThh:mm:ssZ) - # Here, T is the separator between the calendar and the hourly notation - # of time, and Z indicates UTC. (Seconds are required.) - doc = """ - - 1997-07-16T07:30:15Z - - """ - - ts.from_string(doc) - assert ts.timestamp[1] == "dateTime" - assert ts.timestamp[0] == datetime.datetime( - 1997, 7, 16, 7, 30, 15, tzinfo=tzutc() - ) - doc = """ - - 1997-07-16T10:30:15+03:00 - - """ - - ts.from_string(doc) - assert ts.timestamp[1] == "dateTime" - assert ts.timestamp[0] == datetime.datetime( - 1997, 7, 16, 10, 30, 15, tzinfo=tzoffset(None, 10800) - ) - - def test_read_timespan(self): - ts = kml.TimeSpan(ns="") - doc = """ - - 1876-08-01 - 1997-07-16T07:30:15Z - - """ - - ts.from_string(doc) - assert ts.begin[1] == "date" - assert ts.begin[0] == datetime.datetime(1876, 8, 1, 0, 0) - assert ts.end[1] == "dateTime" - assert ts.end[0] == datetime.datetime(1997, 7, 16, 7, 30, 15, tzinfo=tzutc()) - - def test_featurefromstring(self): - d = kml.Document(ns="") - doc = """ - Document.kml - 1 - - 1997-07-16T10:30:15+03:00 - - - 1876-08-01 - 1997-07-16T07:30:15Z - - """ - - d.from_string(doc) - - class TestSetGeometry: def test_altitude_mode(self): geom = Geometry() diff --git a/fastkml/tests/styles_test.py b/tests/styles_test.py similarity index 99% rename from fastkml/tests/styles_test.py rename to tests/styles_test.py index 80762c8c..5926213b 100644 --- a/fastkml/tests/styles_test.py +++ b/tests/styles_test.py @@ -17,8 +17,8 @@ """Test the styles classes.""" from fastkml import styles -from fastkml.tests.base import Lxml -from fastkml.tests.base import StdLibrary +from tests.base import Lxml +from tests.base import StdLibrary class TestStdLibrary(StdLibrary): diff --git a/tests/times_test.py b/tests/times_test.py new file mode 100644 index 00000000..2d0c8c88 --- /dev/null +++ b/tests/times_test.py @@ -0,0 +1,250 @@ +# Copyright (C) 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 +# Software Foundation; either version 2.1 of the License, or (at your option) +# any later version. +# +# This library is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Test the times classes.""" +import datetime + +import pytest +from dateutil.tz import tzoffset +from dateutil.tz import tzutc + +import fastkml as kml +from tests.base import Lxml +from tests.base import StdLibrary + + +class TestStdLibrary(StdLibrary): + """Test with the standard library.""" + + def test_timestamp(self): + now = datetime.datetime.now() + ts = kml.TimeStamp(timestamp=now) + assert ts.timestamp == (now, "dateTime") + assert "TimeStamp>" in str(ts.to_string()) + assert "when>" in str(ts.to_string()) + assert now.isoformat() in str(ts.to_string()) + y2k = datetime.date(2000, 1, 1) + ts = kml.TimeStamp(timestamp=y2k) + assert ts.timestamp == (y2k, "date") + assert "2000-01-01" in str(ts.to_string()) + + def test_timestamp_resolution(self): + now = datetime.datetime.now() + ts = kml.TimeStamp(timestamp=now) + assert now.isoformat() in str(ts.to_string()) + ts.timestamp = (now, "date") + assert now.date().isoformat() in str(ts.to_string()) + assert now.isoformat() not in str(ts.to_string()) + year = str(now.year) + ym = now.strftime("%Y-%m") + ts.timestamp = (now, "gYearMonth") + assert ym in str(ts.to_string()) + assert now.date().isoformat() not in str(ts.to_string()) + ts.timestamp = (now, "gYear") + assert year in str(ts.to_string()) + assert ym not in str(ts.to_string()) + ts.timestamp = None + pytest.raises(TypeError, ts.to_string) + + def test_timespan(self): + now = datetime.datetime.now() + y2k = datetime.datetime(2000, 1, 1) + ts = kml.TimeSpan(end=now, begin=y2k) + assert ts.end == [now, "dateTime"] + assert ts.begin == [y2k, "dateTime"] + assert "TimeSpan>" in str(ts.to_string()) + assert "begin>" in str(ts.to_string()) + assert "end>" in str(ts.to_string()) + assert now.isoformat() in str(ts.to_string()) + assert y2k.isoformat() in str(ts.to_string()) + ts.end = None + assert now.isoformat() not in str(ts.to_string()) + assert y2k.isoformat() in str(ts.to_string()) + ts.begin = None + pytest.raises(ValueError, ts.to_string) + + def test_feature_timestamp(self): + now = datetime.datetime.now() + f = kml.Document() + f.time_stamp = now + assert f.time_stamp == now + assert now.isoformat() in str(f.to_string()) + assert "TimeStamp>" in str(f.to_string()) + assert "when>" in str(f.to_string()) + f.time_stamp = now.date() + assert now.date().isoformat() in str(f.to_string()) + assert now.isoformat() not in str(f.to_string()) + f.time_stamp = None + assert "TimeStamp>" not in str(f.to_string()) + + def test_feature_timespan(self): + now = datetime.datetime.now() + y2k = datetime.date(2000, 1, 1) + f = kml.Document() + f.begin = y2k + f.end = now + assert f.begin == y2k + assert f.end == now + assert now.isoformat() in str(f.to_string()) + assert "2000-01-01" in str(f.to_string()) + assert "TimeSpan>" in str(f.to_string()) + assert "begin>" in str(f.to_string()) + assert "end>" in str(f.to_string()) + f.end = None + assert now.isoformat() not in str(f.to_string()) + assert "2000-01-01" in str(f.to_string()) + assert "TimeSpan>" in str(f.to_string()) + assert "begin>" in str(f.to_string()) + assert "end>" not in str(f.to_string()) + f.begin = None + assert "TimeSpan>" not in str(f.to_string()) + + def test_feature_timespan_stamp(self): + now = datetime.datetime.now() + y2k = datetime.date(2000, 1, 1) + f = kml.Document() + f.begin = y2k + f.end = now + assert now.isoformat() in str(f.to_string()) + assert "2000-01-01" in str(f.to_string()) + assert "TimeSpan>" in str(f.to_string()) + assert "begin>" in str(f.to_string()) + assert "end>" in str(f.to_string()) + assert "TimeStamp>" not in str(f.to_string()) + assert "when>" not in str(f.to_string()) + # when we set a timestamp an existing timespan will be deleted + f.time_stamp = now + assert now.isoformat() in str(f.to_string()) + assert "TimeStamp>" in str(f.to_string()) + assert "when>" in str(f.to_string()) + assert "2000-01-01" not in str(f.to_string()) + assert "TimeSpan>" not in str(f.to_string()) + assert "begin>" not in str(f.to_string()) + assert "end>" not in str(f.to_string()) + # when we set a timespan an existing timestamp will be deleted + f.end = y2k + assert now.isoformat() not in str(f.to_string()) + assert "2000-01-01" in str(f.to_string()) + assert "TimeSpan>" in str(f.to_string()) + assert "begin>" not in str(f.to_string()) + assert "end>" in str(f.to_string()) + assert "TimeStamp>" not in str(f.to_string()) + assert "when>" not in str(f.to_string()) + # We manipulate our Feature so it has timespan and stamp + ts = kml.TimeStamp(timestamp=now) + f._timestamp = ts + # this raises an exception as only either timespan or timestamp + # are allowed not both + pytest.raises(ValueError, f.to_string) + + def test_read_timestamp(self): + ts = kml.TimeStamp(ns="") + doc = """ + + 1997 + + """ + + ts.from_string(doc) + assert ts.timestamp[1] == "gYear" + assert ts.timestamp[0] == datetime.datetime(1997, 1, 1, 0, 0) + doc = """ + + 1997-07 + + """ + + ts.from_string(doc) + assert ts.timestamp[1] == "gYearMonth" + assert ts.timestamp[0] == datetime.datetime(1997, 7, 1, 0, 0) + doc = """ + + 199808 + + """ + + ts.from_string(doc) + assert ts.timestamp[1] == "gYearMonth" + assert ts.timestamp[0] == datetime.datetime(1998, 8, 1, 0, 0) + doc = """ + + 1997-07-16 + + """ + + ts.from_string(doc) + assert ts.timestamp[1] == "date" + assert ts.timestamp[0] == datetime.datetime(1997, 7, 16, 0, 0) + # dateTime (YYYY-MM-DDThh:mm:ssZ) + # Here, T is the separator between the calendar and the hourly notation + # of time, and Z indicates UTC. (Seconds are required.) + doc = """ + + 1997-07-16T07:30:15Z + + """ + + ts.from_string(doc) + assert ts.timestamp[1] == "dateTime" + assert ts.timestamp[0] == datetime.datetime( + 1997, 7, 16, 7, 30, 15, tzinfo=tzutc() + ) + doc = """ + + 1997-07-16T10:30:15+03:00 + + """ + + ts.from_string(doc) + assert ts.timestamp[1] == "dateTime" + assert ts.timestamp[0] == datetime.datetime( + 1997, 7, 16, 10, 30, 15, tzinfo=tzoffset(None, 10800) + ) + + def test_read_timespan(self): + ts = kml.TimeSpan(ns="") + doc = """ + + 1876-08-01 + 1997-07-16T07:30:15Z + + """ + + ts.from_string(doc) + assert ts.begin[1] == "date" + assert ts.begin[0] == datetime.datetime(1876, 8, 1, 0, 0) + assert ts.end[1] == "dateTime" + assert ts.end[0] == datetime.datetime(1997, 7, 16, 7, 30, 15, tzinfo=tzutc()) + + def test_featurefromstring(self): + d = kml.Document(ns="") + doc = """ + Document.kml + 1 + + 1997-07-16T10:30:15+03:00 + + + 1876-08-01 + 1997-07-16T07:30:15Z + + """ + + d.from_string(doc) + + +class TestLxml(Lxml, TestStdLibrary): + """Test with lxml.""" diff --git a/fastkml/tests/views_test.py b/tests/views_test.py similarity index 98% rename from fastkml/tests/views_test.py rename to tests/views_test.py index dc8a736b..2016193d 100644 --- a/fastkml/tests/views_test.py +++ b/tests/views_test.py @@ -20,8 +20,8 @@ from fastkml import times from fastkml import views -from fastkml.tests.base import Lxml -from fastkml.tests.base import StdLibrary +from tests.base import Lxml +from tests.base import StdLibrary class TestStdLibrary(StdLibrary): diff --git a/tox.ini b/tox.ini index aaccd8f3..4c5bbca5 100644 --- a/tox.ini +++ b/tox.ini @@ -32,6 +32,6 @@ max_line_length = 89 ignore= W503 per-file-ignores = - fastkml/tests/*.py: E722,E741,E501,DALL + tests/*.py: E722,E741,E501,DALL examples/*.py: DALL enable-extensions=G From d8b637bfe4903629916a512dbb96ed8c3a31d604 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Wed, 14 Dec 2022 12:55:15 +0000 Subject: [PATCH 04/25] move tests, test times --- .github/workflows/run-all-tests.yml | 4 ++-- setup.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-all-tests.yml b/.github/workflows/run-all-tests.yml index fcd4bc7f..11ce4c60 100644 --- a/.github/workflows/run-all-tests.yml +++ b/.github/workflows/run-all-tests.yml @@ -23,7 +23,7 @@ jobs: pip install -r test-requirements.txt - name: Test with pytest run: | - pytest fastkml + pytest tests cpython-lxml: runs-on: ubuntu-latest @@ -44,7 +44,7 @@ jobs: pip install lxml - name: Test with pytest run: | - pytest fastkml --cov=fastkml --cov-fail-under=88 --cov-report=xml + pytest tests --cov=fastkml --cov-fail-under=88 --cov-report=xml - name: "Upload coverage to Codecov" uses: codecov/codecov-action@v3 with: diff --git a/setup.py b/setup.py index c2a250bb..7a818f81 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,7 @@ def run_tests(self) -> None: "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Intended Audience :: Developers", "License :: OSI Approved :: GNU Library or Lesser General Public License (LGPL)", # "Development Status :: 5 - Production/Stable", From 8967e66a553d796a7f76c823f844c74575438345 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Wed, 14 Dec 2022 12:58:14 +0000 Subject: [PATCH 05/25] move tests, test times --- .github/workflows/run-all-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-all-tests.yml b/.github/workflows/run-all-tests.yml index 11ce4c60..14972357 100644 --- a/.github/workflows/run-all-tests.yml +++ b/.github/workflows/run-all-tests.yml @@ -44,7 +44,7 @@ jobs: pip install lxml - name: Test with pytest run: | - pytest tests --cov=fastkml --cov-fail-under=88 --cov-report=xml + pytest tests --cov=fastkml --cov=tests --cov-fail-under=88 --cov-report=xml - name: "Upload coverage to Codecov" uses: codecov/codecov-action@v3 with: From 8d72a53af70e2680d19caf9b745ab341204ddb1d Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Wed, 14 Dec 2022 20:29:43 +0000 Subject: [PATCH 06/25] create codeql.yml --- .github/workflows/codeql.yml | 76 ++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..78ce7cb6 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,76 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "develop", main ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "develop" ] + schedule: + - cron: '35 10 * * 1' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Use only 'java' to analyze code written in Java, Kotlin or both + # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" From d4cc46e6da329e7df2dc81a7cb89fdd98235ed7d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 19 Dec 2022 16:48:44 +0000 Subject: [PATCH 07/25] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pycqa/isort: 5.10.1 → v5.11.3](https://github.com/pycqa/isort/compare/5.10.1...v5.11.3) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d5f8efe3..907708ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -47,7 +47,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pycqa/isort - rev: 5.10.1 + rev: v5.11.3 hooks: - id: isort - repo: https://github.com/mgedmin/check-manifest From d7bebad4b74ac5739d70848100da858644024475 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 26 Dec 2022 16:50:56 +0000 Subject: [PATCH 08/25] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pycqa/isort: v5.11.3 → 5.11.4](https://github.com/pycqa/isort/compare/v5.11.3...5.11.4) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 907708ff..76c2e53a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -47,7 +47,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pycqa/isort - rev: v5.11.3 + rev: 5.11.4 hooks: - id: isort - repo: https://github.com/mgedmin/check-manifest From 18550d35a8f18894380aafbbb120161195ab81d9 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Fri, 13 Jan 2023 19:47:48 +0000 Subject: [PATCH 09/25] parse gYearMonth with regex --- README.rst | 2 +- fastkml/times.py | 44 +++++++++++++++++++------------------------- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/README.rst b/README.rst index c7bfc638..09c3ea32 100644 --- a/README.rst +++ b/README.rst @@ -109,4 +109,4 @@ Geometry collection (MultiGeometry). You cannot assign different values of 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 implementd. +Please submit a PR with the features you'd like to see implemented. diff --git a/fastkml/times.py b/fastkml/times.py index 9f8ea886..abf832a8 100644 --- a/fastkml/times.py +++ b/fastkml/times.py @@ -1,4 +1,5 @@ """Date and time handling in KML.""" +import re from datetime import date from datetime import datetime from typing import Optional @@ -15,6 +16,12 @@ from fastkml.base import _BaseObject from fastkml.types import Element +# regular expression to parse a gYearMonth string +# year and month may be separated by a dash or not +# year is always 4 digits, month is always 2 digits +# capture groups are named year and month, the dash is not captured +year_month = re.compile(r"^(?P\d{4})(?:-?)(?P\d{2})$") + class _TimePrimitive(_BaseObject): """The dateTime is defined according to XML Schema time. @@ -53,33 +60,20 @@ def get_resolution( return resolution def parse_str(self, datestr: str) -> Tuple[datetime, str]: - resolution = "dateTime" - year = 0 - month = 1 - day = 1 if len(datestr) == 4: - resolution = "gYear" year = int(datestr) - dt = datetime(year, month, day) - elif len(datestr) == 6: - resolution = "gYearMonth" - year = int(datestr[:4]) - month = int(datestr[-2:]) - dt = datetime(year, month, day) - elif len(datestr) == 7: - resolution = "gYearMonth" - year = int(datestr.split("-")[0]) - month = int(datestr.split("-")[1]) - dt = datetime(year, month, day) - elif len(datestr) in [8, 10]: - resolution = "date" - dt = dateutil.parser.parse(datestr) - elif len(datestr) > 10: - resolution = "dateTime" - dt = dateutil.parser.parse(datestr) - else: - raise ValueError - return dt, resolution + return datetime(year, 1, 1), "gYear" + if len(datestr) in {6, 7}: + ym = year_month.match(datestr) + if ym: + year = int(ym.group("year")) + month = int(ym.group("month")) + return datetime(year, month, 1), "gYearMonth" + if len(datestr) in {8, 10}: # 8 is YYYYMMDDS + return dateutil.parser.parse(datestr), "date" + if len(datestr) > 10: + return dateutil.parser.parse(datestr), "dateTime" + raise ValueError def date_to_string( self, From 56955a0dd1346e1e2d7e291a76562699d7a25bd3 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Fri, 13 Jan 2023 20:45:09 +0000 Subject: [PATCH 10/25] Add Kml DateTime class --- fastkml/times.py | 90 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/fastkml/times.py b/fastkml/times.py index abf832a8..fa36b199 100644 --- a/fastkml/times.py +++ b/fastkml/times.py @@ -23,6 +23,94 @@ year_month = re.compile(r"^(?P\d{4})(?:-?)(?P\d{2})$") +class KmlDateTime: + """A KML DateTime object. + + This class is used to parse and format KML DateTime objects. + + A KML DateTime object is a string that conforms to the ISO 8601 standard + for date and time representation. The following formats are supported: + + - yyyy-mm-ddThh:mm:sszzzzzz + - yyyy-mm-ddThh:mm:ss + - yyyy-mm-dd + - yyyy-mm + - yyyy + + The T is the separator between the date and the time, and the time zone + is either Z (for UTC) or zzzzzz, which represents ±hh:mm in relation to + UTC. Additionally, the value can be expressed as a date only. + + The precision of the DateTime is dictated by the DateTime value + which can be one of the following: + + - dateTime gives second resolution + - date gives day resolution + - gYearMonth gives month resolution + - gYear gives year resolution + + The KmlDateTime class can be used to parse a KML DateTime string into a + Python datetime object, or to format a Python datetime object into a + KML DateTime string. + + The KmlDateTime class is used by the TimeStamp and TimeSpan classes. + """ + + def __init__( + self, + dt: Union[date, datetime], + resolution: Optional[str] = None, + ): + """Initialize a KmlDateTime object.""" + self.dt = dt + self.resolution = resolution + if resolution is None: + # sourcery skip: swap-if-expression + self.resolution = "date" if not isinstance(dt, datetime) else "dateTime" + + def __str__(self) -> str: + """Return the KML DateTime string representation of the object.""" + if self.resolution == "gYear": + return self.dt.strftime("%Y") + if self.resolution == "gYearMonth": + return self.dt.strftime("%Y-%m") + if self.resolution == "date": + return ( + self.dt.date().isoformat() + if isinstance(self.dt, datetime) + else self.dt.isoformat() + ) + if self.resolution == "dateTime": + return self.dt.isoformat() + raise ValueError + + @classmethod + def parse(cls, datestr: str) -> "KmlDateTime": + """Parse a KML DateTime string into a KmlDateTime object.""" + resolution = None + dt = None + if len(datestr) == 4: + year = int(datestr) + dt = datetime(year, 1, 1) + resolution = "gYear" + elif len(datestr) in {6, 7}: + ym = year_month.match(datestr) + if ym: + year = int(ym.group("year")) + month = int(ym.group("month")) + dt = datetime(year, month, 1) + resolution = "gYearMonth" + elif len(datestr) in {8, 10}: # 8 is YYYYMMDDS + dt = dateutil.parser.parse(datestr) + resolution = "date" + elif len(datestr) > 10: + dt = dateutil.parser.parse(datestr) + resolution = "dateTime" + if dt is None: + raise ValueError + return cls(dt, resolution) + + class _TimePrimitive(_BaseObject): """The dateTime is defined according to XML Schema time. The value can be expressed as yyyy-mm-ddThh:mm:sszzzzzz, where T is @@ -37,6 +125,8 @@ class _TimePrimitive(_BaseObject): - date gives day resolution - gYearMonth gives month resolution - gYear gives year resolution + + https://developers.google.com/kml/documentation/kmlreference#timeprimitive """ RESOLUTIONS = ["gYear", "gYearMonth", "date", "dateTime"] From 6bca13b447794eeba8f7bb929f27eb12fc9f67b5 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Fri, 13 Jan 2023 21:24:17 +0000 Subject: [PATCH 11/25] use enum instead of string --- fastkml/enums.py | 27 +++++++++++++++++++ fastkml/times.py | 70 +++++++++++++++++++++++++++++------------------- 2 files changed, 69 insertions(+), 28 deletions(-) create mode 100644 fastkml/enums.py diff --git a/fastkml/enums.py b/fastkml/enums.py new file mode 100644 index 00000000..b875796c --- /dev/null +++ b/fastkml/enums.py @@ -0,0 +1,27 @@ +# Copyright (C) 2023 Christian Ledermann +# +# This library is free software; you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation; either version 2.1 of the License, or (at your option) +# any later version. +# +# This library is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +from enum import Enum +from enum import unique + + +@unique +class DateTimeResolution(Enum): + """Enum to represent the different date time resolutions.""" + + datetime = "dateTime" + date = "date" + year_month = "gYearMonth" + year = "gYear" diff --git a/fastkml/times.py b/fastkml/times.py index fa36b199..a902cd14 100644 --- a/fastkml/times.py +++ b/fastkml/times.py @@ -1,3 +1,18 @@ +# 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 +# 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 """Date and time handling in KML.""" import re from datetime import date @@ -14,12 +29,12 @@ import fastkml.config as config from fastkml.base import _BaseObject +from fastkml.enums import DateTimeResolution from fastkml.types import Element # regular expression to parse a gYearMonth string # year and month may be separated by a dash or not # year is always 4 digits, month is always 2 digits -# capture groups are named year and month, the dash is not captured year_month = re.compile(r"^(?P\d{4})(?:-?)(?P\d{2})$") @@ -59,73 +74,69 @@ class KmlDateTime: def __init__( self, dt: Union[date, datetime], - resolution: Optional[str] = None, + resolution: Optional[DateTimeResolution] = None, ): """Initialize a KmlDateTime object.""" self.dt = dt self.resolution = resolution if resolution is None: # sourcery skip: swap-if-expression - self.resolution = "date" if not isinstance(dt, datetime) else "dateTime" + self.resolution = ( + DateTimeResolution.date + if not isinstance(dt, datetime) + else DateTimeResolution.datetime + ) + + def __bool__(self) -> bool: + """Return True if the date or datetime is valid.""" + return isinstance(self.dt, date) def __str__(self) -> str: """Return the KML DateTime string representation of the object.""" - if self.resolution == "gYear": + if self.resolution == DateTimeResolution.year: return self.dt.strftime("%Y") - if self.resolution == "gYearMonth": + if self.resolution == DateTimeResolution.year_month: return self.dt.strftime("%Y-%m") - if self.resolution == "date": + if self.resolution == DateTimeResolution.date: return ( self.dt.date().isoformat() if isinstance(self.dt, datetime) else self.dt.isoformat() ) - if self.resolution == "dateTime": + if self.resolution == DateTimeResolution.datetime: return self.dt.isoformat() raise ValueError @classmethod - def parse(cls, datestr: str) -> "KmlDateTime": + def parse(cls, datestr: str) -> Optional["KmlDateTime"]: """Parse a KML DateTime string into a KmlDateTime object.""" resolution = None dt = None if len(datestr) == 4: year = int(datestr) dt = datetime(year, 1, 1) - resolution = "gYear" + resolution = DateTimeResolution.year elif len(datestr) in {6, 7}: ym = year_month.match(datestr) if ym: year = int(ym.group("year")) month = int(ym.group("month")) dt = datetime(year, month, 1) - resolution = "gYearMonth" + resolution = DateTimeResolution.year_month elif len(datestr) in {8, 10}: # 8 is YYYYMMDDS dt = dateutil.parser.parse(datestr) - resolution = "date" + resolution = DateTimeResolution.date elif len(datestr) > 10: dt = dateutil.parser.parse(datestr) - resolution = "dateTime" - if dt is None: - raise ValueError - return cls(dt, resolution) + resolution = DateTimeResolution.datetime + return cls(dt, resolution) if dt else None class _TimePrimitive(_BaseObject): - """The dateTime is defined according to XML Schema time. - The value can be expressed as yyyy-mm-ddThh:mm:sszzzzzz, where T is - the separator between the date and the time, and the time zone is - either Z (for UTC) or zzzzzz, which represents ±hh:mm in relation to - UTC. Additionally, the value can be expressed as a date only. - - The precision of the dateTime is dictated by the dateTime value - which can be one of the following: - - - dateTime gives second resolution - - date gives day resolution - - gYearMonth gives month resolution - - gYear gives year resolution + """ + This is an abstract element and cannot be used directly in a KML file. + This element is extended by the and elements. https://developers.google.com/kml/documentation/kmlreference#timeprimitive """ @@ -136,6 +147,7 @@ def get_resolution( dt: Optional[Union[date, datetime]], resolution: Optional[str] = None, ) -> Optional[str]: + # XXX deprecated use KmlDateTime if resolution: if resolution not in self.RESOLUTIONS: raise ValueError @@ -150,6 +162,7 @@ def get_resolution( return resolution def parse_str(self, datestr: str) -> Tuple[datetime, str]: + # XXX deprecated use KmlDateTime.parse if len(datestr) == 4: year = int(datestr) return datetime(year, 1, 1), "gYear" @@ -170,6 +183,7 @@ def date_to_string( dt: Optional[Union[date, datetime]], resolution: Optional[str] = None, ) -> Optional[str]: + # XXX deprecated use KmlDateTime.__str__ if isinstance(dt, (date, datetime)): resolution = self.get_resolution(dt, resolution) if resolution == "gYear": From 7053d1e91cea5df47e4e1dae140ceebed762ab91 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Fri, 13 Jan 2023 21:43:13 +0000 Subject: [PATCH 12/25] add tests for KmlDateTime --- tests/times_test.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/times_test.py b/tests/times_test.py index 2d0c8c88..3987342b 100644 --- a/tests/times_test.py +++ b/tests/times_test.py @@ -22,6 +22,8 @@ from dateutil.tz import tzutc import fastkml as kml +from fastkml.enums import DateTimeResolution +from fastkml.times import KmlDateTime from tests.base import Lxml from tests.base import StdLibrary @@ -29,6 +31,42 @@ class TestStdLibrary(StdLibrary): """Test with the standard library.""" + def test_kml_datetime_year(self): + dt = datetime.datetime(2000, 1, 1, tzinfo=tzutc()) + kdt = KmlDateTime(dt, DateTimeResolution.year) + assert kdt.resolution == DateTimeResolution.year + assert str(kdt) == "2000" + + def test_kml_datetime_year_month(self): + dt = datetime.datetime(2000, 3, 1, tzinfo=tzutc()) + kdt = KmlDateTime(dt, DateTimeResolution.year_month) + assert kdt.resolution == DateTimeResolution.year_month + assert str(kdt) == "2000-03" + + def test_kml_datetime_date(self): + dt = datetime.datetime.now() + kdt = KmlDateTime(dt, DateTimeResolution.date) + assert kdt.resolution == DateTimeResolution.date + assert str(kdt) == dt.date().isoformat() + + def test_kml_datetime_date_implicit(self): + dt = datetime.date.today() + kdt = KmlDateTime(dt) + assert kdt.resolution == DateTimeResolution.date + assert str(kdt) == dt.isoformat() + + def test_kml_datetime_datetime(self): + dt = datetime.datetime.now() + kdt = KmlDateTime(dt, DateTimeResolution.datetime) + assert kdt.resolution == DateTimeResolution.datetime + assert str(kdt) == dt.isoformat() + + def test_kml_datetime_datetime_impicit(self): + dt = datetime.datetime.now() + kdt = KmlDateTime(dt) + assert kdt.resolution == DateTimeResolution.datetime + assert str(kdt) == dt.isoformat() + def test_timestamp(self): now = datetime.datetime.now() ts = kml.TimeStamp(timestamp=now) From d98a85d83a4f1dc02c07bdc6097c9e4b491d15f6 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Fri, 13 Jan 2023 21:43:52 +0000 Subject: [PATCH 13/25] fix typo --- tests/times_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/times_test.py b/tests/times_test.py index 3987342b..c0885bf8 100644 --- a/tests/times_test.py +++ b/tests/times_test.py @@ -61,7 +61,7 @@ def test_kml_datetime_datetime(self): assert kdt.resolution == DateTimeResolution.datetime assert str(kdt) == dt.isoformat() - def test_kml_datetime_datetime_impicit(self): + def test_kml_datetime_datetime_implicit(self): dt = datetime.datetime.now() kdt = KmlDateTime(dt) assert kdt.resolution == DateTimeResolution.datetime From 5e937c5554aa0337d97026e4e7306a0c67726d80 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Fri, 13 Jan 2023 21:57:41 +0000 Subject: [PATCH 14/25] No need to run KmlDateTime Test with lxml --- tests/times_test.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/times_test.py b/tests/times_test.py index c0885bf8..dbdc9090 100644 --- a/tests/times_test.py +++ b/tests/times_test.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 @@ -28,17 +28,17 @@ from tests.base import StdLibrary -class TestStdLibrary(StdLibrary): - """Test with the standard library.""" +class TestDateTime: + """KmlDateTime implementation is independent of XML parser.""" def test_kml_datetime_year(self): - dt = datetime.datetime(2000, 1, 1, tzinfo=tzutc()) + dt = datetime.datetime(2000, 1, 1) kdt = KmlDateTime(dt, DateTimeResolution.year) assert kdt.resolution == DateTimeResolution.year assert str(kdt) == "2000" def test_kml_datetime_year_month(self): - dt = datetime.datetime(2000, 3, 1, tzinfo=tzutc()) + dt = datetime.datetime(2000, 3, 1) kdt = KmlDateTime(dt, DateTimeResolution.year_month) assert kdt.resolution == DateTimeResolution.year_month assert str(kdt) == "2000-03" @@ -67,6 +67,10 @@ def test_kml_datetime_datetime_implicit(self): assert kdt.resolution == DateTimeResolution.datetime assert str(kdt) == dt.isoformat() + +class TestStdLibrary(StdLibrary): + """Test with the standard library.""" + def test_timestamp(self): now = datetime.datetime.now() ts = kml.TimeStamp(timestamp=now) From 9e46f568a868faa87712e23a7a76ab787f43c792 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Fri, 13 Jan 2023 22:02:41 +0000 Subject: [PATCH 15/25] Test KmlDateTime with StdLibrary --- tests/times_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/times_test.py b/tests/times_test.py index dbdc9090..c043498a 100644 --- a/tests/times_test.py +++ b/tests/times_test.py @@ -28,7 +28,7 @@ from tests.base import StdLibrary -class TestDateTime: +class TestDateTime(StdLibrary): """KmlDateTime implementation is independent of XML parser.""" def test_kml_datetime_year(self): From 4d76558982f7d1f21201b42ec6e0835405acc286 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Fri, 13 Jan 2023 22:29:59 +0000 Subject: [PATCH 16/25] Test edgecase when none is passed as dt to KmlDateTime --- tests/times_test.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/times_test.py b/tests/times_test.py index c043498a..a1eca986 100644 --- a/tests/times_test.py +++ b/tests/times_test.py @@ -33,39 +33,66 @@ class TestDateTime(StdLibrary): def test_kml_datetime_year(self): dt = datetime.datetime(2000, 1, 1) + kdt = KmlDateTime(dt, DateTimeResolution.year) + assert kdt.resolution == DateTimeResolution.year assert str(kdt) == "2000" + assert bool(kdt) def test_kml_datetime_year_month(self): dt = datetime.datetime(2000, 3, 1) + kdt = KmlDateTime(dt, DateTimeResolution.year_month) + assert kdt.resolution == DateTimeResolution.year_month assert str(kdt) == "2000-03" + assert bool(kdt) def test_kml_datetime_date(self): dt = datetime.datetime.now() + kdt = KmlDateTime(dt, DateTimeResolution.date) + assert kdt.resolution == DateTimeResolution.date assert str(kdt) == dt.date().isoformat() + assert bool(kdt) def test_kml_datetime_date_implicit(self): dt = datetime.date.today() + kdt = KmlDateTime(dt) + assert kdt.resolution == DateTimeResolution.date assert str(kdt) == dt.isoformat() + assert bool(kdt) def test_kml_datetime_datetime(self): dt = datetime.datetime.now() + kdt = KmlDateTime(dt, DateTimeResolution.datetime) + assert kdt.resolution == DateTimeResolution.datetime assert str(kdt) == dt.isoformat() + assert bool(kdt) def test_kml_datetime_datetime_implicit(self): dt = datetime.datetime.now() + kdt = KmlDateTime(dt) + assert kdt.resolution == DateTimeResolution.datetime assert str(kdt) == dt.isoformat() + assert bool(kdt) + + def test_kml_datetime_no_datetime(self): + """When we pass dt as None bool() should return False.""" + kdt = KmlDateTime(None) + + assert kdt.resolution == DateTimeResolution.date + assert not bool(kdt) + with pytest.raises(AttributeError): + str(kdt) class TestStdLibrary(StdLibrary): From 44ddd3e539302f1ab258d405ddbe7671dce72ce1 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Fri, 13 Jan 2023 23:00:28 +0000 Subject: [PATCH 17/25] add parse datetime tests --- tests/times_test.py | 74 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/times_test.py b/tests/times_test.py index a1eca986..f1d6da4e 100644 --- a/tests/times_test.py +++ b/tests/times_test.py @@ -94,6 +94,80 @@ def test_kml_datetime_no_datetime(self): with pytest.raises(AttributeError): str(kdt) + def test_parse_year(self): + dt = KmlDateTime.parse("2000") + + assert dt.resolution == DateTimeResolution.year + assert dt.dt == datetime.datetime(2000, 1, 1) + + def test_parse_year_0(self): + with pytest.raises(ValueError): + KmlDateTime.parse("0000") + + def test_parse_year_month(self): + dt = KmlDateTime.parse("2000-03") + + assert dt.resolution == DateTimeResolution.year_month + assert dt.dt == datetime.datetime(2000, 3, 1) + + def test_parse_year_month_no_dash(self): + dt = KmlDateTime.parse("200004") + + assert dt.resolution == DateTimeResolution.year_month + assert dt.dt == datetime.datetime(2000, 4, 1) + + def test_parse_year_month_0(self): + with pytest.raises(ValueError): + KmlDateTime.parse("2000-00") + + def test_parse_year_month_13(self): + with pytest.raises(ValueError): + KmlDateTime.parse("2000-13") + + def test_parse_year_month_day(self): + dt = KmlDateTime.parse("2000-03-01") + + assert dt.resolution == DateTimeResolution.date + assert dt.dt == datetime.datetime(2000, 3, 1) + + def test_parse_year_month_day_no_dash(self): + dt = KmlDateTime.parse("20000401") + + assert dt.resolution == DateTimeResolution.date + assert dt.dt == datetime.datetime(2000, 4, 1) + + def test_parse_year_month_day_0(self): + with pytest.raises(ValueError): + KmlDateTime.parse("2000-05-00") + + def test_parse_datetime_utc(self): + dt = KmlDateTime.parse("1997-07-16T07:30:15Z") + + assert dt.resolution == DateTimeResolution.datetime + assert dt.dt == datetime.datetime(1997, 7, 16, 7, 30, 15, tzinfo=tzutc()) + + def test_parse_datetime_with_tz(self): + dt = KmlDateTime.parse("1997-07-16T07:30:15+01:00") + + assert dt.resolution == DateTimeResolution.datetime + assert dt.dt == datetime.datetime( + 1997, 7, 16, 7, 30, 15, tzinfo=tzoffset(None, 3600) + ) + + def test_parse_datetime_with_tz_no_colon(self): + dt = KmlDateTime.parse("1997-07-16T07:30:15+0100") + + assert dt.resolution == DateTimeResolution.datetime + assert dt.dt == datetime.datetime( + 1997, 7, 16, 7, 30, 15, tzinfo=tzoffset(None, 3600) + ) + + def test_parse_datetime_no_tz(self): + dt = KmlDateTime.parse("1997-07-16T07:30:15") + + assert dt.resolution == DateTimeResolution.datetime + assert dt.dt == datetime.datetime(1997, 7, 16, 7, 30, 15) + class TestStdLibrary(StdLibrary): """Test with the standard library.""" From 28115e1e4e86372b47d9ac1fb6d3517830e6fa63 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Fri, 13 Jan 2023 23:26:08 +0000 Subject: [PATCH 18/25] parse datetime edge cases --- tests/times_test.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/times_test.py b/tests/times_test.py index f1d6da4e..65fe2943 100644 --- a/tests/times_test.py +++ b/tests/times_test.py @@ -168,6 +168,13 @@ def test_parse_datetime_no_tz(self): assert dt.resolution == DateTimeResolution.datetime assert dt.dt == datetime.datetime(1997, 7, 16, 7, 30, 15) + def test_parse_datetime_empty(self): + assert KmlDateTime.parse("") is None + + def test_parse_year_month_5(self): + """Test that a single digit month is invalid.""" + assert KmlDateTime.parse("19973") is None + class TestStdLibrary(StdLibrary): """Test with the standard library.""" From 114f693c30abfea5749b5b9bc26fdf0591cc673b Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sat, 14 Jan 2023 00:47:26 +0000 Subject: [PATCH 19/25] refactor TimeStamp to use KmlDateTime --- fastkml/kml.py | 14 ++++++----- fastkml/times.py | 20 +++++++++------ fastkml/views.py | 10 +++----- tests/times_test.py | 60 +++++++++++++++++---------------------------- tests/views_test.py | 6 ++--- 5 files changed, 50 insertions(+), 60 deletions(-) diff --git a/fastkml/kml.py b/fastkml/kml.py index a53ecb91..287684c3 100644 --- a/fastkml/kml.py +++ b/fastkml/kml.py @@ -201,15 +201,17 @@ def style_url(self, styleurl: Union[str, StyleUrl, None]) -> None: raise ValueError @property - def time_stamp(self): + def time_stamp(self) -> Optional[TimeStamp]: """This just returns the datetime portion of the timestamp""" - if self._timestamp is not None: - return self._timestamp.timestamp[0] + if self._timestamp: + return self._timestamp @time_stamp.setter - def time_stamp(self, dt): - self._timestamp = None if dt is None else TimeStamp(timestamp=dt) - if self._timespan is not None: + def time_stamp(self, timestamp: Optional[TimeStamp]) -> None: + self._timestamp = timestamp + if self._timespan is None: + return + if timestamp: logger.warning("Setting a TimeStamp, TimeSpan deleted") self._timespan = None diff --git a/fastkml/times.py b/fastkml/times.py index a902cd14..49c5a24a 100644 --- a/fastkml/times.py +++ b/fastkml/times.py @@ -91,6 +91,14 @@ def __bool__(self) -> bool: """Return True if the date or datetime is valid.""" return isinstance(self.dt, date) + def __eq__(self, other: object) -> bool: + """Return True if the two objects are equal.""" + return ( + self.dt == other.dt and self.resolution == other.resolution + if isinstance(other, KmlDateTime) + else False + ) + def __str__(self) -> str: """Return the KML DateTime string representation of the object.""" if self.resolution == DateTimeResolution.year: @@ -123,7 +131,7 @@ def parse(cls, datestr: str) -> Optional["KmlDateTime"]: month = int(ym.group("month")) dt = datetime(year, month, 1) resolution = DateTimeResolution.year_month - elif len(datestr) in {8, 10}: # 8 is YYYYMMDDS + elif len(datestr) in {8, 10}: # 8 is YYYYMMDD, 10 is YYYY-MM-DD dt = dateutil.parser.parse(datestr) resolution = DateTimeResolution.date elif len(datestr) > 10: @@ -212,26 +220,24 @@ def __init__( ns: Optional[str] = None, id: Optional[str] = None, target_id: Optional[str] = None, - timestamp: Optional[Union[date, datetime]] = None, - resolution: Optional[str] = None, + timestamp: Optional[KmlDateTime] = None, ) -> None: super().__init__(ns=ns, id=id, target_id=target_id) - resolution = self.get_resolution(timestamp, resolution) - self.timestamp = (timestamp, resolution) + self.timestamp = timestamp def etree_element(self) -> Element: element = super().etree_element() when = config.etree.SubElement( # type: ignore[attr-defined] element, f"{self.ns}when" ) - when.text = self.date_to_string(*self.timestamp) + when.text = str(self.timestamp) return element def from_element(self, element: Element) -> None: super().from_element(element) when = element.find(f"{self.ns}when") if when is not None: - self.timestamp = self.parse_str(when.text) + self.timestamp = KmlDateTime.parse(when.text) class TimeSpan(_TimePrimitive): diff --git a/fastkml/views.py b/fastkml/views.py index c393d94b..6c40ca60 100644 --- a/fastkml/views.py +++ b/fastkml/views.py @@ -88,14 +88,12 @@ def __init__( self._timestamp = time_primitive @property - def timestamp(self) -> Optional[datetime.datetime]: - if self._timestamp is not None: - return self._timestamp.timestamp[0] - return None + def timestamp(self) -> TimeStamp: + return self._timestamp @timestamp.setter - def timestamp(self, dt: datetime.datetime) -> None: - self._timestamp = None if dt is None else TimeStamp(timestamp=dt) + def timestamp(self, timestamp: Optional[TimeStamp]) -> None: + self._timestamp = timestamp if self._timestamp is not None: logger.warning("Setting a TimeStamp, TimeSpan deleted") self._timespan = None diff --git a/tests/times_test.py b/tests/times_test.py index 65fe2943..cb6b0221 100644 --- a/tests/times_test.py +++ b/tests/times_test.py @@ -181,34 +181,18 @@ class TestStdLibrary(StdLibrary): def test_timestamp(self): now = datetime.datetime.now() - ts = kml.TimeStamp(timestamp=now) - assert ts.timestamp == (now, "dateTime") + dt = KmlDateTime(now) + ts = kml.TimeStamp(timestamp=dt) + assert ts.timestamp.dt == now + assert ts.timestamp.resolution == DateTimeResolution.datetime assert "TimeStamp>" in str(ts.to_string()) assert "when>" in str(ts.to_string()) assert now.isoformat() in str(ts.to_string()) - y2k = datetime.date(2000, 1, 1) + y2k = KmlDateTime(datetime.date(2000, 1, 1)) ts = kml.TimeStamp(timestamp=y2k) - assert ts.timestamp == (y2k, "date") + assert ts.timestamp == y2k assert "2000-01-01" in str(ts.to_string()) - def test_timestamp_resolution(self): - now = datetime.datetime.now() - ts = kml.TimeStamp(timestamp=now) - assert now.isoformat() in str(ts.to_string()) - ts.timestamp = (now, "date") - assert now.date().isoformat() in str(ts.to_string()) - assert now.isoformat() not in str(ts.to_string()) - year = str(now.year) - ym = now.strftime("%Y-%m") - ts.timestamp = (now, "gYearMonth") - assert ym in str(ts.to_string()) - assert now.date().isoformat() not in str(ts.to_string()) - ts.timestamp = (now, "gYear") - assert year in str(ts.to_string()) - assert ym not in str(ts.to_string()) - ts.timestamp = None - pytest.raises(TypeError, ts.to_string) - def test_timespan(self): now = datetime.datetime.now() y2k = datetime.datetime(2000, 1, 1) @@ -229,12 +213,12 @@ def test_timespan(self): def test_feature_timestamp(self): now = datetime.datetime.now() f = kml.Document() - f.time_stamp = now - assert f.time_stamp == now + f.time_stamp = kml.TimeStamp(timestamp=KmlDateTime(now)) + assert f.time_stamp.timestamp == KmlDateTime(now) assert now.isoformat() in str(f.to_string()) assert "TimeStamp>" in str(f.to_string()) assert "when>" in str(f.to_string()) - f.time_stamp = now.date() + f.time_stamp = kml.TimeStamp(timestamp=KmlDateTime(now.date())) assert now.date().isoformat() in str(f.to_string()) assert now.isoformat() not in str(f.to_string()) f.time_stamp = None @@ -276,7 +260,7 @@ def test_feature_timespan_stamp(self): assert "TimeStamp>" not in str(f.to_string()) assert "when>" not in str(f.to_string()) # when we set a timestamp an existing timespan will be deleted - f.time_stamp = now + f.time_stamp = kml.TimeStamp(timestamp=KmlDateTime(now)) assert now.isoformat() in str(f.to_string()) assert "TimeStamp>" in str(f.to_string()) assert "when>" in str(f.to_string()) @@ -309,8 +293,8 @@ def test_read_timestamp(self): """ ts.from_string(doc) - assert ts.timestamp[1] == "gYear" - assert ts.timestamp[0] == datetime.datetime(1997, 1, 1, 0, 0) + assert ts.timestamp.resolution == DateTimeResolution.year + assert ts.timestamp.dt == datetime.datetime(1997, 1, 1, 0, 0) doc = """ 1997-07 @@ -318,8 +302,8 @@ def test_read_timestamp(self): """ ts.from_string(doc) - assert ts.timestamp[1] == "gYearMonth" - assert ts.timestamp[0] == datetime.datetime(1997, 7, 1, 0, 0) + assert ts.timestamp.resolution == DateTimeResolution.year_month + assert ts.timestamp.dt == datetime.datetime(1997, 7, 1, 0, 0) doc = """ 199808 @@ -327,8 +311,8 @@ def test_read_timestamp(self): """ ts.from_string(doc) - assert ts.timestamp[1] == "gYearMonth" - assert ts.timestamp[0] == datetime.datetime(1998, 8, 1, 0, 0) + assert ts.timestamp.resolution == DateTimeResolution.year_month + assert ts.timestamp.dt == datetime.datetime(1998, 8, 1, 0, 0) doc = """ 1997-07-16 @@ -336,8 +320,8 @@ def test_read_timestamp(self): """ ts.from_string(doc) - assert ts.timestamp[1] == "date" - assert ts.timestamp[0] == datetime.datetime(1997, 7, 16, 0, 0) + assert ts.timestamp.resolution == DateTimeResolution.date + assert ts.timestamp.dt == datetime.datetime(1997, 7, 16, 0, 0) # dateTime (YYYY-MM-DDThh:mm:ssZ) # Here, T is the separator between the calendar and the hourly notation # of time, and Z indicates UTC. (Seconds are required.) @@ -348,8 +332,8 @@ def test_read_timestamp(self): """ ts.from_string(doc) - assert ts.timestamp[1] == "dateTime" - assert ts.timestamp[0] == datetime.datetime( + assert ts.timestamp.resolution == DateTimeResolution.datetime + assert ts.timestamp.dt == datetime.datetime( 1997, 7, 16, 7, 30, 15, tzinfo=tzutc() ) doc = """ @@ -359,8 +343,8 @@ def test_read_timestamp(self): """ ts.from_string(doc) - assert ts.timestamp[1] == "dateTime" - assert ts.timestamp[0] == datetime.datetime( + assert ts.timestamp.resolution == DateTimeResolution.datetime + assert ts.timestamp.dt == datetime.datetime( 1997, 7, 16, 10, 30, 15, tzinfo=tzoffset(None, 10800) ) diff --git a/tests/views_test.py b/tests/views_test.py index 2016193d..e3ca615b 100644 --- a/tests/views_test.py +++ b/tests/views_test.py @@ -98,7 +98,7 @@ def test_camera_read(self) -> None: def test_create_look_at(self) -> None: time_stamp = times.TimeStamp( id="time-span-id", - timestamp=datetime.datetime(2019, 1, 1), + timestamp=times.KmlDateTime(datetime.datetime(2019, 1, 1)), ) look_at = views.LookAt( @@ -121,7 +121,7 @@ def test_create_look_at(self) -> None: assert look_at.longitude == 60 assert look_at.id == "look-at-id" assert look_at.target_id == "target-look-at-id" - assert look_at.timestamp == datetime.datetime(2019, 1, 1) + assert look_at.timestamp.timestamp.dt == datetime.datetime(2019, 1, 1) assert look_at.begin is None assert look_at.end is None assert look_at.to_string() @@ -153,7 +153,7 @@ def test_look_at_read(self) -> None: assert look_at.longitude == 60 assert look_at.id == "look-at-id" assert look_at.target_id == "target-look-at-id" - assert look_at.timestamp == datetime.datetime(2019, 1, 1) + assert look_at.timestamp.timestamp.dt == datetime.datetime(2019, 1, 1) assert look_at.begin is None assert look_at.end is None From d2d81dbfbe3c65f8f17d32f51063017839b04d5f Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sat, 14 Jan 2023 02:19:51 +0000 Subject: [PATCH 20/25] refactor TimeSpan #188 --- fastkml/kml.py | 29 ++++++------- fastkml/times.py | 100 +++++++++----------------------------------- fastkml/views.py | 34 +++++++-------- tests/times_test.py | 38 ++++++++--------- tests/views_test.py | 12 +++--- 5 files changed, 77 insertions(+), 136 deletions(-) diff --git a/fastkml/kml.py b/fastkml/kml.py index 287684c3..f3de3e0a 100644 --- a/fastkml/kml.py +++ b/fastkml/kml.py @@ -42,6 +42,7 @@ from fastkml.styles import StyleMap from fastkml.styles import StyleUrl from fastkml.styles import _StyleSelector +from fastkml.times import KmlDateTime from fastkml.times import TimeSpan from fastkml.times import TimeStamp from fastkml.types import Element @@ -216,36 +217,36 @@ def time_stamp(self, timestamp: Optional[TimeStamp]) -> None: self._timespan = None @property - def begin(self): + def begin(self) -> Optional[KmlDateTime]: if self._timespan is not None: - return self._timespan.begin[0] + return self._timespan.begin @begin.setter - def begin(self, dt): + def begin(self, dt: Optional[KmlDateTime]): if self._timespan is None: self._timespan = TimeSpan(begin=dt) - elif self._timespan.begin is None: - self._timespan.begin = [dt, None] + elif dt is None and self._timespan.end is None: + self._timespan = None else: - self._timespan.begin[0] = dt - if self._timestamp is not None: + self._timespan.begin = dt + if self._timespan and self._timestamp: logger.warning("Setting a TimeSpan, TimeStamp deleted") self._timestamp = None @property - def end(self): + def end(self) -> Optional[KmlDateTime]: if self._timespan is not None: - return self._timespan.end[0] + return self._timespan.end @end.setter - def end(self, dt): + def end(self, dt: Optional[KmlDateTime]): if self._timespan is None: self._timespan = TimeSpan(end=dt) - elif self._timespan.end is None: - self._timespan.end = [dt, None] + elif dt is None and self._timespan.begin is None: + self._timespan = None else: - self._timespan.end[0] = dt - if self._timestamp is not None: + self._timespan.end = dt + if self._timespan and self._timestamp: logger.warning("Setting a TimeSpan, TimeStamp deleted") self._timestamp = None diff --git a/fastkml/times.py b/fastkml/times.py index 49c5a24a..574a547d 100644 --- a/fastkml/times.py +++ b/fastkml/times.py @@ -18,7 +18,6 @@ from datetime import date from datetime import datetime from typing import Optional -from typing import Tuple from typing import Union # note that there are some ISO 8601 timeparsers at pypi @@ -99,6 +98,10 @@ def __eq__(self, other: object) -> bool: else False ) + def __repr__(self) -> str: + """Return a string representation of the object.""" + return f"{self.__class__.__name__}({self.dt!r}, {self.resolution})" + def __str__(self) -> str: """Return the KML DateTime string representation of the object.""" if self.resolution == DateTimeResolution.year: @@ -148,72 +151,11 @@ class _TimePrimitive(_BaseObject): https://developers.google.com/kml/documentation/kmlreference#timeprimitive """ - RESOLUTIONS = ["gYear", "gYearMonth", "date", "dateTime"] - - def get_resolution( - self, - dt: Optional[Union[date, datetime]], - resolution: Optional[str] = None, - ) -> Optional[str]: - # XXX deprecated use KmlDateTime - if resolution: - if resolution not in self.RESOLUTIONS: - raise ValueError - else: - return resolution - elif isinstance(dt, datetime): - resolution = "dateTime" - elif isinstance(dt, date): - resolution = "date" - else: - resolution = None - return resolution - - def parse_str(self, datestr: str) -> Tuple[datetime, str]: - # XXX deprecated use KmlDateTime.parse - if len(datestr) == 4: - year = int(datestr) - return datetime(year, 1, 1), "gYear" - if len(datestr) in {6, 7}: - ym = year_month.match(datestr) - if ym: - year = int(ym.group("year")) - month = int(ym.group("month")) - return datetime(year, month, 1), "gYearMonth" - if len(datestr) in {8, 10}: # 8 is YYYYMMDDS - return dateutil.parser.parse(datestr), "date" - if len(datestr) > 10: - return dateutil.parser.parse(datestr), "dateTime" - raise ValueError - - def date_to_string( - self, - dt: Optional[Union[date, datetime]], - resolution: Optional[str] = None, - ) -> Optional[str]: - # XXX deprecated use KmlDateTime.__str__ - if isinstance(dt, (date, datetime)): - resolution = self.get_resolution(dt, resolution) - if resolution == "gYear": - return dt.strftime("%Y") - elif resolution == "gYearMonth": - return dt.strftime("%Y-%m") - elif resolution == "date": - return ( - dt.date().isoformat() - if isinstance(dt, datetime) - else dt.isoformat() - ) - elif resolution == "dateTime": - return dt.isoformat() - return None - class TimeStamp(_TimePrimitive): """Represents a single moment in time.""" __name__ = "TimeStamp" - timestamp: Optional[Tuple[datetime, str]] = None def __init__( self, @@ -244,47 +186,45 @@ class TimeSpan(_TimePrimitive): """Represents an extent in time bounded by begin and end dateTimes.""" __name__ = "TimeSpan" - begin = None - end = None def __init__( self, ns: Optional[str] = None, id: Optional[str] = None, target_id: Optional[str] = None, - begin: Optional[Union[date, datetime]] = None, - begin_res: None = None, - end: Optional[Union[date, datetime]] = None, - end_res: None = None, + begin: Optional[KmlDateTime] = None, + end: Optional[KmlDateTime] = None, ) -> None: super().__init__(ns=ns, id=id, target_id=target_id) - if begin: - resolution = self.get_resolution(begin, begin_res) - self.begin = [begin, resolution] - if end: - resolution = self.get_resolution(end, end_res) - self.end = [end, resolution] + self.begin = begin + self.end = end def from_element(self, element: Element) -> None: super().from_element(element) begin = element.find(f"{self.ns}begin") if begin is not None: - self.begin = self.parse_str(begin.text) + self.begin = KmlDateTime.parse(begin.text) end = element.find(f"{self.ns}end") if end is not None: - self.end = self.parse_str(end.text) + self.end = KmlDateTime.parse(end.text) def etree_element(self) -> Element: element = super().etree_element() if self.begin is not None: - text = self.date_to_string(*self.begin) + text = str(self.begin) if text: - begin = config.etree.SubElement(element, f"{self.ns}begin") + begin = config.etree.SubElement( # type: ignore[attr-defined] + element, + f"{self.ns}begin", + ) begin.text = text if self.end is not None: - text = self.date_to_string(*self.end) + text = str(self.end) if text: - end = config.etree.SubElement(element, f"{self.ns}end") + end = config.etree.SubElement( # type: ignore[attr-defined] + element, + f"{self.ns}end", + ) end.text = text if self.begin == self.end is None: raise ValueError("Either begin, end or both must be set") diff --git a/fastkml/views.py b/fastkml/views.py index 6c40ca60..5fc98c67 100644 --- a/fastkml/views.py +++ b/fastkml/views.py @@ -1,4 +1,3 @@ -import datetime import logging from typing import Optional from typing import Union @@ -6,6 +5,7 @@ import fastkml.config as config import fastkml.gx as gx from fastkml.base import _BaseObject +from fastkml.times import KmlDateTime from fastkml.times import TimeSpan from fastkml.times import TimeStamp from fastkml.types import Element @@ -99,36 +99,36 @@ def timestamp(self, timestamp: Optional[TimeStamp]) -> None: self._timespan = None @property - def begin(self) -> Optional[datetime.datetime]: - if self._timespan is None: - return None - return self._timespan.begin[0] + def begin(self) -> Optional[KmlDateTime]: + if self._timespan is not None: + return self._timespan.begin @begin.setter - def begin(self, dt) -> None: + def begin(self, dt: Optional[KmlDateTime]): if self._timespan is None: self._timespan = TimeSpan(begin=dt) - elif self._timespan.begin is None: - self._timespan.begin = [dt, None] + elif dt is None and self._timespan.end is None: + self._timespan = None else: - self._timespan.begin[0] = dt - if self._timestamp is not None: + self._timespan.begin = dt + if self._timespan and self._timestamp: logger.warning("Setting a TimeSpan, TimeStamp deleted") self._timestamp = None @property - def end(self) -> Optional[datetime.datetime]: - return None if self._timespan is None else self._timespan.end[0] + def end(self) -> Optional[KmlDateTime]: + if self._timespan is not None: + return self._timespan.end @end.setter - def end(self, dt) -> None: + def end(self, dt: Optional[KmlDateTime]): if self._timespan is None: self._timespan = TimeSpan(end=dt) - elif self._timespan.end is None: - self._timespan.end = [dt, None] + elif dt is None and self._timespan.begin is None: + self._timespan = None else: - self._timespan.end[0] = dt - if self._timestamp is not None: + self._timespan.end = dt + if self._timespan and self._timestamp: logger.warning("Setting a TimeSpan, TimeStamp deleted") self._timestamp = None diff --git a/tests/times_test.py b/tests/times_test.py index cb6b0221..fb9cf238 100644 --- a/tests/times_test.py +++ b/tests/times_test.py @@ -194,19 +194,19 @@ def test_timestamp(self): assert "2000-01-01" in str(ts.to_string()) def test_timespan(self): - now = datetime.datetime.now() - y2k = datetime.datetime(2000, 1, 1) + now = KmlDateTime(datetime.datetime.now()) + y2k = KmlDateTime(datetime.datetime(2000, 1, 1)) ts = kml.TimeSpan(end=now, begin=y2k) - assert ts.end == [now, "dateTime"] - assert ts.begin == [y2k, "dateTime"] + assert ts.end == now + assert ts.begin == y2k assert "TimeSpan>" in str(ts.to_string()) assert "begin>" in str(ts.to_string()) assert "end>" in str(ts.to_string()) - assert now.isoformat() in str(ts.to_string()) - assert y2k.isoformat() in str(ts.to_string()) + assert now.dt.isoformat() in str(ts.to_string()) + assert y2k.dt.isoformat() in str(ts.to_string()) ts.end = None - assert now.isoformat() not in str(ts.to_string()) - assert y2k.isoformat() in str(ts.to_string()) + assert now.dt.isoformat() not in str(ts.to_string()) + assert y2k.dt.isoformat() in str(ts.to_string()) ts.begin = None pytest.raises(ValueError, ts.to_string) @@ -226,12 +226,12 @@ def test_feature_timestamp(self): def test_feature_timespan(self): now = datetime.datetime.now() - y2k = datetime.date(2000, 1, 1) + y2k = datetime.datetime(2000, 1, 1) f = kml.Document() - f.begin = y2k - f.end = now - assert f.begin == y2k - assert f.end == now + f.begin = KmlDateTime(y2k) + f.end = KmlDateTime(now) + assert f.begin == KmlDateTime(y2k) + assert f.end == KmlDateTime(now) assert now.isoformat() in str(f.to_string()) assert "2000-01-01" in str(f.to_string()) assert "TimeSpan>" in str(f.to_string()) @@ -250,8 +250,8 @@ def test_feature_timespan_stamp(self): now = datetime.datetime.now() y2k = datetime.date(2000, 1, 1) f = kml.Document() - f.begin = y2k - f.end = now + f.begin = KmlDateTime(y2k) + f.end = KmlDateTime(now) assert now.isoformat() in str(f.to_string()) assert "2000-01-01" in str(f.to_string()) assert "TimeSpan>" in str(f.to_string()) @@ -358,10 +358,10 @@ def test_read_timespan(self): """ ts.from_string(doc) - assert ts.begin[1] == "date" - assert ts.begin[0] == datetime.datetime(1876, 8, 1, 0, 0) - assert ts.end[1] == "dateTime" - assert ts.end[0] == datetime.datetime(1997, 7, 16, 7, 30, 15, tzinfo=tzutc()) + assert ts.begin.resolution == DateTimeResolution.date + assert ts.begin.dt == datetime.datetime(1876, 8, 1, 0, 0) + assert ts.end.resolution == DateTimeResolution.datetime + assert ts.end.dt == datetime.datetime(1997, 7, 16, 7, 30, 15, tzinfo=tzutc()) def test_featurefromstring(self): d = kml.Document(ns="") diff --git a/tests/views_test.py b/tests/views_test.py index e3ca615b..c3820664 100644 --- a/tests/views_test.py +++ b/tests/views_test.py @@ -31,8 +31,8 @@ def test_create_camera(self) -> None: """Test the creation of a camera.""" time_span = times.TimeSpan( id="time-span-id", - begin=datetime.datetime(2019, 1, 1), - end=datetime.datetime(2019, 1, 2), + begin=times.KmlDateTime(datetime.datetime(2019, 1, 1)), + end=times.KmlDateTime(datetime.datetime(2019, 1, 2)), ) camera = views.Camera( @@ -57,8 +57,8 @@ def test_create_camera(self) -> None: assert camera.longitude == 60 assert camera.id == "cam-id" assert camera.target_id == "target-cam-id" - assert camera.begin == datetime.datetime(2019, 1, 1) - assert camera.end == datetime.datetime(2019, 1, 2) + assert camera.begin == times.KmlDateTime(datetime.datetime(2019, 1, 1)) + assert camera.end == times.KmlDateTime(datetime.datetime(2019, 1, 2)) assert camera.to_string() def test_camera_read(self) -> None: @@ -92,8 +92,8 @@ def test_camera_read(self) -> None: assert camera.longitude == 60 assert camera.id == "cam-id" assert camera.target_id == "target-cam-id" - assert camera.begin == datetime.datetime(2019, 1, 1) - assert camera.end == datetime.datetime(2019, 1, 2) + assert camera.begin == times.KmlDateTime(datetime.datetime(2019, 1, 1)) + assert camera.end == times.KmlDateTime(datetime.datetime(2019, 1, 2)) def test_create_look_at(self) -> None: time_stamp = times.TimeStamp( From 8a5110b99ef7d9fd0689661ae01096f25440baf4 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sat, 14 Jan 2023 02:35:56 +0000 Subject: [PATCH 21/25] common time mixin --- fastkml/kml.py | 53 ++---------------------------- fastkml/mixins.py | 79 +++++++++++++++++++++++++++++++++++++++++++++ fastkml/views.py | 52 ++--------------------------- tests/views_test.py | 4 +-- 4 files changed, 85 insertions(+), 103 deletions(-) create mode 100644 fastkml/mixins.py diff --git a/fastkml/kml.py b/fastkml/kml.py index f3de3e0a..2b18d466 100644 --- a/fastkml/kml.py +++ b/fastkml/kml.py @@ -38,11 +38,11 @@ from fastkml.data import ExtendedData from fastkml.data import Schema from fastkml.geometry import Geometry +from fastkml.mixins import TimeMixin from fastkml.styles import Style from fastkml.styles import StyleMap from fastkml.styles import StyleUrl from fastkml.styles import _StyleSelector -from fastkml.times import KmlDateTime from fastkml.times import TimeSpan from fastkml.times import TimeStamp from fastkml.types import Element @@ -52,7 +52,7 @@ logger = logging.getLogger(__name__) -class _Feature(_BaseObject): +class _Feature(TimeMixin, _BaseObject): """ abstract element; do not create subclasses are: @@ -201,55 +201,6 @@ def style_url(self, styleurl: Union[str, StyleUrl, None]) -> None: else: raise ValueError - @property - def time_stamp(self) -> Optional[TimeStamp]: - """This just returns the datetime portion of the timestamp""" - if self._timestamp: - return self._timestamp - - @time_stamp.setter - def time_stamp(self, timestamp: Optional[TimeStamp]) -> None: - self._timestamp = timestamp - if self._timespan is None: - return - if timestamp: - logger.warning("Setting a TimeStamp, TimeSpan deleted") - self._timespan = None - - @property - def begin(self) -> Optional[KmlDateTime]: - if self._timespan is not None: - return self._timespan.begin - - @begin.setter - def begin(self, dt: Optional[KmlDateTime]): - if self._timespan is None: - self._timespan = TimeSpan(begin=dt) - elif dt is None and self._timespan.end is None: - self._timespan = None - else: - self._timespan.begin = dt - if self._timespan and self._timestamp: - logger.warning("Setting a TimeSpan, TimeStamp deleted") - self._timestamp = None - - @property - def end(self) -> Optional[KmlDateTime]: - if self._timespan is not None: - return self._timespan.end - - @end.setter - def end(self, dt: Optional[KmlDateTime]): - if self._timespan is None: - self._timespan = TimeSpan(end=dt) - elif dt is None and self._timespan.begin is None: - self._timespan = None - else: - self._timespan.end = dt - if self._timespan and self._timestamp: - logger.warning("Setting a TimeSpan, TimeStamp deleted") - self._timestamp = None - @property def camera(self): return self._camera diff --git a/fastkml/mixins.py b/fastkml/mixins.py new file mode 100644 index 00000000..3630318f --- /dev/null +++ b/fastkml/mixins.py @@ -0,0 +1,79 @@ +# Copyright (C) 2023 Christian Ledermann +# +# This library is free software; you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation; either version 2.1 of the License, or (at your option) +# any later version. +# +# This library is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this library; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +"""Mixins for the KML classes.""" +import logging +from typing import Optional + +from fastkml.times import KmlDateTime +from fastkml.times import TimeSpan +from fastkml.times import TimeStamp + +logger = logging.getLogger(__name__) + + +class TimeMixin: + + _timespan: Optional[TimeSpan] = None + _timestamp: Optional[TimeStamp] = None + + @property + def time_stamp(self) -> Optional[TimeStamp]: + """This just returns the datetime portion of the timestamp""" + if self._timestamp: + return self._timestamp + + @time_stamp.setter + def time_stamp(self, timestamp: Optional[TimeStamp]) -> None: + self._timestamp = timestamp + if self._timespan is None: + return + if timestamp: + logger.warning("Setting a TimeStamp, TimeSpan deleted") + self._timespan = None + + @property + def begin(self) -> Optional[KmlDateTime]: + if self._timespan is not None: + return self._timespan.begin + + @begin.setter + def begin(self, dt: Optional[KmlDateTime]): + if self._timespan is None: + self._timespan = TimeSpan(begin=dt) + elif dt is None and self._timespan.end is None: + self._timespan = None + else: + self._timespan.begin = dt + if self._timespan and self._timestamp: + logger.warning("Setting a TimeSpan, TimeStamp deleted") + self._timestamp = None + + @property + def end(self) -> Optional[KmlDateTime]: + if self._timespan is not None: + return self._timespan.end + + @end.setter + def end(self, dt: Optional[KmlDateTime]): + if self._timespan is None: + self._timespan = TimeSpan(end=dt) + elif dt is None and self._timespan.begin is None: + self._timespan = None + else: + self._timespan.end = dt + if self._timespan and self._timestamp: + logger.warning("Setting a TimeSpan, TimeStamp deleted") + self._timestamp = None diff --git a/fastkml/views.py b/fastkml/views.py index 5fc98c67..c7d9c03e 100644 --- a/fastkml/views.py +++ b/fastkml/views.py @@ -5,7 +5,7 @@ import fastkml.config as config import fastkml.gx as gx from fastkml.base import _BaseObject -from fastkml.times import KmlDateTime +from fastkml.mixins import TimeMixin from fastkml.times import TimeSpan from fastkml.times import TimeStamp from fastkml.types import Element @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) -class _AbstractView(_BaseObject): +class _AbstractView(TimeMixin, _BaseObject): """ This is an abstract element and cannot be used directly in a KML file. This element is extended by the and elements. @@ -59,9 +59,6 @@ class _AbstractView(_BaseObject): # absolute - # Interprets the as a value in meters above sea level. - _timespan: Optional[TimeSpan] = None - _timestamp: Optional[TimeStamp] = None - def __init__( self, ns: Optional[str] = None, @@ -87,51 +84,6 @@ def __init__( elif isinstance(time_primitive, TimeStamp): self._timestamp = time_primitive - @property - def timestamp(self) -> TimeStamp: - return self._timestamp - - @timestamp.setter - def timestamp(self, timestamp: Optional[TimeStamp]) -> None: - self._timestamp = timestamp - if self._timestamp is not None: - logger.warning("Setting a TimeStamp, TimeSpan deleted") - self._timespan = None - - @property - def begin(self) -> Optional[KmlDateTime]: - if self._timespan is not None: - return self._timespan.begin - - @begin.setter - def begin(self, dt: Optional[KmlDateTime]): - if self._timespan is None: - self._timespan = TimeSpan(begin=dt) - elif dt is None and self._timespan.end is None: - self._timespan = None - else: - self._timespan.begin = dt - if self._timespan and self._timestamp: - logger.warning("Setting a TimeSpan, TimeStamp deleted") - self._timestamp = None - - @property - def end(self) -> Optional[KmlDateTime]: - if self._timespan is not None: - return self._timespan.end - - @end.setter - def end(self, dt: Optional[KmlDateTime]): - if self._timespan is None: - self._timespan = TimeSpan(end=dt) - elif dt is None and self._timespan.begin is None: - self._timespan = None - else: - self._timespan.end = dt - if self._timespan and self._timestamp: - logger.warning("Setting a TimeSpan, TimeStamp deleted") - self._timestamp = None - @property def longitude(self) -> Optional[float]: return self._longitude diff --git a/tests/views_test.py b/tests/views_test.py index c3820664..3b886a72 100644 --- a/tests/views_test.py +++ b/tests/views_test.py @@ -121,7 +121,7 @@ def test_create_look_at(self) -> None: assert look_at.longitude == 60 assert look_at.id == "look-at-id" assert look_at.target_id == "target-look-at-id" - assert look_at.timestamp.timestamp.dt == datetime.datetime(2019, 1, 1) + assert look_at._timestamp.timestamp.dt == datetime.datetime(2019, 1, 1) assert look_at.begin is None assert look_at.end is None assert look_at.to_string() @@ -153,7 +153,7 @@ def test_look_at_read(self) -> None: assert look_at.longitude == 60 assert look_at.id == "look-at-id" assert look_at.target_id == "target-look-at-id" - assert look_at.timestamp.timestamp.dt == datetime.datetime(2019, 1, 1) + assert look_at._timestamp.timestamp.dt == datetime.datetime(2019, 1, 1) assert look_at.begin is None assert look_at.end is None From d5ed6f3906bf88b4e1c6b9ba9c9f2bd29c87874c Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sat, 14 Jan 2023 02:57:53 +0000 Subject: [PATCH 22/25] refactor timestam getter and setter --- fastkml/mixins.py | 26 ++++++++++++++++---------- tests/times_test.py | 8 ++++---- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/fastkml/mixins.py b/fastkml/mixins.py index 3630318f..6399b9fe 100644 --- a/fastkml/mixins.py +++ b/fastkml/mixins.py @@ -30,17 +30,21 @@ class TimeMixin: _timestamp: Optional[TimeStamp] = None @property - def time_stamp(self) -> Optional[TimeStamp]: + def time_stamp(self) -> Optional[KmlDateTime]: """This just returns the datetime portion of the timestamp""" - if self._timestamp: - return self._timestamp + if self._timestamp is not None: + return self._timestamp.timestamp + return None @time_stamp.setter - def time_stamp(self, timestamp: Optional[TimeStamp]) -> None: - self._timestamp = timestamp - if self._timespan is None: - return - if timestamp: + def time_stamp(self, timestamp: Optional[KmlDateTime]) -> None: + if self._timestamp is None: + self._timestamp = TimeStamp(timestamp=timestamp) + elif timestamp is None: + self._timestamp = None + else: + self._timestamp.timestamp = timestamp + if self._timespan and self._timestamp: logger.warning("Setting a TimeStamp, TimeSpan deleted") self._timespan = None @@ -48,9 +52,10 @@ def time_stamp(self, timestamp: Optional[TimeStamp]) -> None: def begin(self) -> Optional[KmlDateTime]: if self._timespan is not None: return self._timespan.begin + return None @begin.setter - def begin(self, dt: Optional[KmlDateTime]): + def begin(self, dt: Optional[KmlDateTime]) -> None: if self._timespan is None: self._timespan = TimeSpan(begin=dt) elif dt is None and self._timespan.end is None: @@ -65,9 +70,10 @@ def begin(self, dt: Optional[KmlDateTime]): def end(self) -> Optional[KmlDateTime]: if self._timespan is not None: return self._timespan.end + return None @end.setter - def end(self, dt: Optional[KmlDateTime]): + def end(self, dt: Optional[KmlDateTime]) -> None: if self._timespan is None: self._timespan = TimeSpan(end=dt) elif dt is None and self._timespan.begin is None: diff --git a/tests/times_test.py b/tests/times_test.py index fb9cf238..d660c429 100644 --- a/tests/times_test.py +++ b/tests/times_test.py @@ -213,12 +213,12 @@ def test_timespan(self): def test_feature_timestamp(self): now = datetime.datetime.now() f = kml.Document() - f.time_stamp = kml.TimeStamp(timestamp=KmlDateTime(now)) - assert f.time_stamp.timestamp == KmlDateTime(now) + f.time_stamp = KmlDateTime(now) + assert f.time_stamp.dt == now assert now.isoformat() in str(f.to_string()) assert "TimeStamp>" in str(f.to_string()) assert "when>" in str(f.to_string()) - f.time_stamp = kml.TimeStamp(timestamp=KmlDateTime(now.date())) + f.time_stamp = KmlDateTime(now.date()) assert now.date().isoformat() in str(f.to_string()) assert now.isoformat() not in str(f.to_string()) f.time_stamp = None @@ -260,7 +260,7 @@ def test_feature_timespan_stamp(self): assert "TimeStamp>" not in str(f.to_string()) assert "when>" not in str(f.to_string()) # when we set a timestamp an existing timespan will be deleted - f.time_stamp = kml.TimeStamp(timestamp=KmlDateTime(now)) + f.time_stamp = KmlDateTime(now) assert now.isoformat() in str(f.to_string()) assert "TimeStamp>" in str(f.to_string()) assert "when>" in str(f.to_string()) From b2cfa3941eb154064afe21c334df65db58332ccc Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sat, 14 Jan 2023 03:10:53 +0000 Subject: [PATCH 23/25] bump version to alpha.4 --- .github/workflows/codeql.yml | 60 ++++++++++++++++++------------------ setup.py | 2 +- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 78ce7cb6..abe79d57 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,10 +13,10 @@ name: "CodeQL" on: push: - branches: [ "develop", main ] + branches: ["develop", "main"] pull_request: # The branches below must be a subset of the branches above - branches: [ "develop" ] + branches: ["develop"] schedule: - cron: '35 10 * * 1' @@ -32,45 +32,45 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'python' ] + language: ["python"] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Use only 'java' to analyze code written in Java, Kotlin or both # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout repository + uses: actions/checkout@v3 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. - # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v2 + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/setup.py b/setup.py index 7a818f81..5bfb0fe7 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ def run_tests(self) -> None: setup( name="fastkml", - version="1.0.alpha.3", + version="1.0.alpha.4", description="Fast KML processing in python", long_description=( open("README.rst").read() From 48673e161921217b6658082fbd0802e191c9518d Mon Sep 17 00:00:00 2001 From: Sourcery AI <> Date: Sat, 14 Jan 2023 03:11:33 +0000 Subject: [PATCH 24/25] 'Refactored by Sourcery' --- fastkml/mixins.py | 12 +++--------- fastkml/views.py | 10 ++++------ 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/fastkml/mixins.py b/fastkml/mixins.py index 6399b9fe..2ddc0d77 100644 --- a/fastkml/mixins.py +++ b/fastkml/mixins.py @@ -32,9 +32,7 @@ class TimeMixin: @property def time_stamp(self) -> Optional[KmlDateTime]: """This just returns the datetime portion of the timestamp""" - if self._timestamp is not None: - return self._timestamp.timestamp - return None + return self._timestamp.timestamp if self._timestamp is not None else None @time_stamp.setter def time_stamp(self, timestamp: Optional[KmlDateTime]) -> None: @@ -50,9 +48,7 @@ def time_stamp(self, timestamp: Optional[KmlDateTime]) -> None: @property def begin(self) -> Optional[KmlDateTime]: - if self._timespan is not None: - return self._timespan.begin - return None + return self._timespan.begin if self._timespan is not None else None @begin.setter def begin(self, dt: Optional[KmlDateTime]) -> None: @@ -68,9 +64,7 @@ def begin(self, dt: Optional[KmlDateTime]) -> None: @property def end(self) -> Optional[KmlDateTime]: - if self._timespan is not None: - return self._timespan.end - return None + return self._timespan.end if self._timespan is not None else None @end.setter def end(self, dt: Optional[KmlDateTime]) -> None: diff --git a/fastkml/views.py b/fastkml/views.py index c7d9c03e..5b7d9e77 100644 --- a/fastkml/views.py +++ b/fastkml/views.py @@ -155,8 +155,8 @@ def altitude_mode(self) -> str: @altitude_mode.setter def altitude_mode(self, mode: str) -> None: - if mode in ("relativeToGround", "clampToGround", "absolute"): - self._altitude_mode = str(mode) + if mode in {"relativeToGround", "clampToGround", "absolute"}: + self._altitude_mode = mode else: self._altitude_mode = "relativeToGround" # raise ValueError( @@ -181,11 +181,9 @@ def from_element(self, element: Element): if tilt is not None: self.tilt = tilt.text altitude_mode = element.find(f"{self.ns}altitudeMode") - if altitude_mode is not None: - self.altitude_mode = altitude_mode.text - else: + if altitude_mode is None: altitude_mode = element.find(f"{gx.NS}altitudeMode") - self.altitude_mode = altitude_mode.text + self.altitude_mode = altitude_mode.text timespan = element.find(f"{self.ns}TimeSpan") if timespan is not None: s = TimeSpan(self.ns) From 09f02210b0ec3bc1ec408b074f337ca919e8c787 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sat, 14 Jan 2023 12:50:09 +0000 Subject: [PATCH 25/25] add link to documentation --- fastkml/geometry.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/fastkml/geometry.py b/fastkml/geometry.py index 3d6f4eec..62b0c5ab 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -105,6 +105,8 @@ def __init__( 3 meters above sea level, the placemark will appear elevated above the terrain by 7 meters. A typical use of this mode is for aircraft placement. + + https://developers.google.com/kml/documentation/kmlreference#geometry """ super().__init__(ns, id) self.extrude = extrude