diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..abe79d57 --- /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}}" diff --git a/.github/workflows/run-all-tests.yml b/.github/workflows/run-all-tests.yml index bb9cfeb2..14972357 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: @@ -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 @@ -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: @@ -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=tests --cov-fail-under=88 --cov-report=xml - name: "Upload coverage to Codecov" uses: codecov/codecov-action@v3 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: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0e5df5a7..76c2e53a 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 @@ -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 @@ -47,7 +47,7 @@ repos: hooks: - id: flake8 - repo: https://github.com/pycqa/isort - rev: 5.10.1 + rev: 5.11.4 hooks: - id: isort - repo: https://github.com/mgedmin/check-manifest 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/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/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/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/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 diff --git a/fastkml/kml.py b/fastkml/kml.py index 6ef4d66c..2b18d466 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 @@ -38,6 +38,7 @@ 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 @@ -51,7 +52,7 @@ logger = logging.getLogger(__name__) -class _Feature(_BaseObject): +class _Feature(TimeMixin, _BaseObject): """ abstract element; do not create subclasses are: @@ -200,53 +201,6 @@ def style_url(self, styleurl: Union[str, StyleUrl, None]) -> None: else: raise ValueError - @property - def time_stamp(self): - """This just returns the datetime portion of the timestamp""" - if self._timestamp is not None: - return self._timestamp.timestamp[0] - - @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: - logger.warning("Setting a TimeStamp, TimeSpan deleted") - self._timespan = None - - @property - def begin(self): - if self._timespan is not None: - return self._timespan.begin[0] - - @begin.setter - def begin(self, dt): - if self._timespan is None: - self._timespan = TimeSpan(begin=dt) - elif self._timespan.begin is None: - self._timespan.begin = [dt, None] - else: - self._timespan.begin[0] = dt - if self._timestamp is not None: - logger.warning("Setting a TimeSpan, TimeStamp deleted") - self._timestamp = None - - @property - def end(self): - if self._timespan is not None: - return self._timespan.end[0] - - @end.setter - def end(self, dt): - if self._timespan is None: - self._timespan = TimeSpan(end=dt) - elif self._timespan.end is None: - self._timespan.end = [dt, None] - else: - self._timespan.end[0] = dt - if self._timestamp is not None: - 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..2ddc0d77 --- /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[KmlDateTime]: + """This just returns the datetime portion of the timestamp""" + return self._timestamp.timestamp if self._timestamp is not None else None + + @time_stamp.setter + 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 + + @property + def begin(self) -> Optional[KmlDateTime]: + return self._timespan.begin if self._timespan is not None else None + + @begin.setter + 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: + 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]: + return self._timespan.end if self._timespan is not None else None + + @end.setter + 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: + 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/times.py b/fastkml/times.py index f991a4b0..574a547d 100644 --- a/fastkml/times.py +++ b/fastkml/times.py @@ -1,7 +1,22 @@ +# 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 from datetime import datetime -from typing import List from typing import Optional from typing import Union @@ -13,174 +28,203 @@ 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 +year_month = re.compile(r"^(?P\d{4})(?:-?)(?P\d{2})$") -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 + +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 + 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 - """ - RESOLUTIONS = ["gYear", "gYearMonth", "date", "dateTime"] + 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 get_resolution( + def __init__( self, - dt: Optional[Union[date, datetime]], - resolution: Optional[str] = None, - ) -> Optional[str]: - 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) -> List[Union[datetime, str]]: - resolution = "dateTime" - year = 0 - month = 1 - day = 1 + dt: Union[date, datetime], + 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 = ( + 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 __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 __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: + return self.dt.strftime("%Y") + if self.resolution == DateTimeResolution.year_month: + return self.dt.strftime("%Y-%m") + if self.resolution == DateTimeResolution.date: + return ( + self.dt.date().isoformat() + if isinstance(self.dt, datetime) + else self.dt.isoformat() + ) + if self.resolution == DateTimeResolution.datetime: + return self.dt.isoformat() + raise ValueError + + @classmethod + def parse(cls, datestr: str) -> Optional["KmlDateTime"]: + """Parse a KML DateTime string into a KmlDateTime object.""" + resolution = None + dt = None 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 = datetime(year, 1, 1) + 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 = DateTimeResolution.year_month + 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: - resolution = "dateTime" dt = dateutil.parser.parse(datestr) - else: - raise ValueError - return [dt, resolution] + resolution = DateTimeResolution.datetime + return cls(dt, resolution) if dt else None - def date_to_string( - self, - dt: Optional[Union[date, datetime]], - resolution: Optional[str] = None, - ) -> Optional[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 _TimePrimitive(_BaseObject): + """ + 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 + """ class TimeStamp(_TimePrimitive): """Represents a single moment in time.""" __name__ = "TimeStamp" - timestamp = None def __init__( self, 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): """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 9d86bd1b..5b7d9e77 100644 --- a/fastkml/views.py +++ b/fastkml/views.py @@ -5,6 +5,7 @@ import fastkml.config as config import fastkml.gx as gx from fastkml.base import _BaseObject +from fastkml.mixins import TimeMixin from fastkml.times import TimeSpan from fastkml.times import TimeStamp from fastkml.types import Element @@ -12,30 +13,30 @@ 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. """ - _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,9 +59,6 @@ class _AbstractView(_BaseObject): # absolute - # Interprets the as a value in meters above sea level. - _timespan = None - _timestamp = None - def __init__( self, ns: Optional[str] = None, @@ -86,52 +84,6 @@ def __init__( elif isinstance(time_primitive, TimeStamp): self._timestamp = time_primitive - @property - def timestamp(self): - if self._timestamp is not None: - return self._timestamp.timestamp[0] - - @timestamp.setter - def timestamp(self, dt): - 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): - if self._timespan is None: - return None - return self._timespan.begin[0] - - @begin.setter - def begin(self, dt) -> None: - if self._timespan is None: - self._timespan = TimeSpan(begin=dt) - elif self._timespan.begin is None: - self._timespan.begin = [dt, None] - else: - self._timespan.begin[0] = dt - if self._timestamp is not None: - logger.warning("Setting a TimeSpan, TimeStamp deleted") - self._timestamp = None - - @property - def end(self): - return None if self._timespan is None else self._timespan.end[0] - - @end.setter - def end(self, dt): - if self._timespan is None: - self._timespan = TimeSpan(end=dt) - elif self._timespan.end is None: - self._timespan.end = [dt, None] - else: - self._timespan.end[0] = dt - if self._timestamp is not None: - logger.warning("Setting a TimeSpan, TimeStamp deleted") - self._timestamp = None - @property def longitude(self) -> Optional[float]: return self._longitude @@ -202,16 +154,16 @@ def altitude_mode(self) -> str: return self._altitude_mode @altitude_mode.setter - def altitude_mode(self, mode) -> None: - if mode in ("relativeToGround", "clampToGround", "absolute"): - self._altitude_mode = str(mode) + def altitude_mode(self, mode: str) -> None: + if mode in {"relativeToGround", "clampToGround", "absolute"}: + self._altitude_mode = mode else: self._altitude_mode = "relativeToGround" # raise ValueError( # "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: @@ -229,11 +181,9 @@ def from_element(self, 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) @@ -245,7 +195,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 +253,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 +285,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 +316,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 +349,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 +361,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/setup.py b/setup.py index c2a250bb..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() @@ -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", 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..d660c429 --- /dev/null +++ b/tests/times_test.py @@ -0,0 +1,384 @@ +# 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 +# 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 fastkml.enums import DateTimeResolution +from fastkml.times import KmlDateTime +from tests.base import Lxml +from tests.base import StdLibrary + + +class TestDateTime(StdLibrary): + """KmlDateTime implementation is independent of XML parser.""" + + 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) + + 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) + + 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.""" + + def test_timestamp(self): + now = datetime.datetime.now() + 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 = KmlDateTime(datetime.date(2000, 1, 1)) + ts = kml.TimeStamp(timestamp=y2k) + assert ts.timestamp == y2k + assert "2000-01-01" in str(ts.to_string()) + + def test_timespan(self): + now = KmlDateTime(datetime.datetime.now()) + y2k = KmlDateTime(datetime.datetime(2000, 1, 1)) + ts = kml.TimeSpan(end=now, begin=y2k) + 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.dt.isoformat() in str(ts.to_string()) + assert y2k.dt.isoformat() in str(ts.to_string()) + ts.end = None + 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) + + def test_feature_timestamp(self): + now = datetime.datetime.now() + f = kml.Document() + 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 = 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 + assert "TimeStamp>" not in str(f.to_string()) + + def test_feature_timespan(self): + now = datetime.datetime.now() + y2k = datetime.datetime(2000, 1, 1) + f = kml.Document() + 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()) + 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 = 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()) + 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 = KmlDateTime(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.resolution == DateTimeResolution.year + assert ts.timestamp.dt == datetime.datetime(1997, 1, 1, 0, 0) + doc = """ + + 1997-07 + + """ + + ts.from_string(doc) + assert ts.timestamp.resolution == DateTimeResolution.year_month + assert ts.timestamp.dt == datetime.datetime(1997, 7, 1, 0, 0) + doc = """ + + 199808 + + """ + + ts.from_string(doc) + assert ts.timestamp.resolution == DateTimeResolution.year_month + assert ts.timestamp.dt == datetime.datetime(1998, 8, 1, 0, 0) + doc = """ + + 1997-07-16 + + """ + + ts.from_string(doc) + 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.) + doc = """ + + 1997-07-16T07:30:15Z + + """ + + ts.from_string(doc) + assert ts.timestamp.resolution == DateTimeResolution.datetime + assert ts.timestamp.dt == 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.resolution == DateTimeResolution.datetime + assert ts.timestamp.dt == 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.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="") + 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 87% rename from fastkml/tests/views_test.py rename to tests/views_test.py index dc8a736b..3b886a72 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): @@ -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,13 +92,13 @@ 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( 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 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