From 45bbc7bfca367ae99de49e46ee8892790bc8b3dc Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Tue, 5 Nov 2024 18:27:54 +0000 Subject: [PATCH 01/18] add hypothesis tests for TimeStamp and TimeSpan --- fastkml/times.py | 23 ++++++++-- tests/hypothesis/gx_test.py | 14 +------ tests/hypothesis/strategies.py | 26 ++++++++++++ tests/hypothesis/times_test.py | 76 ++++++++++++++++++++++++++++++++++ tests/times_test.py | 22 +++++----- 5 files changed, 134 insertions(+), 27 deletions(-) create mode 100644 tests/hypothesis/times_test.py diff --git a/fastkml/times.py b/fastkml/times.py index 62b06fd9..e33c71b3 100644 --- a/fastkml/times.py +++ b/fastkml/times.py @@ -88,15 +88,30 @@ def __init__( resolution: Optional[DateTimeResolution] = None, ) -> None: """Initialize a KmlDateTime object.""" - self.dt = dt - self.resolution = resolution if resolution is None: # sourcery skip: swap-if-expression - self.resolution = ( + resolution = ( DateTimeResolution.date if not isinstance(dt, datetime) else DateTimeResolution.datetime ) + dt = ( + dt.date() + if isinstance(dt, datetime) and resolution != DateTimeResolution.datetime + else dt + ) + if resolution == DateTimeResolution.year: + self.dt = date(dt.year, 1, 1) + elif resolution == DateTimeResolution.year_month: + self.dt = date(dt.year, dt.month, 1) + else: + self.dt = dt + self.resolution = ( + DateTimeResolution.date + if not isinstance(self.dt, datetime) + and resolution == DateTimeResolution.datetime + else resolution + ) def __repr__(self) -> str: """Create a string (c)representation for KmlDateTime.""" @@ -142,7 +157,7 @@ def parse(cls, datestr: str) -> Optional["KmlDateTime"]: year = int(year_month_day_match.group("year")) month = int(year_month_day_match.group("month") or 1) day = int(year_month_day_match.group("day") or 1) - dt = arrow.get(year, month, day).datetime + dt = date(year, month, day) resolution = DateTimeResolution.date if year_month_day_match.group("day") is None: resolution = DateTimeResolution.year_month diff --git a/tests/hypothesis/gx_test.py b/tests/hypothesis/gx_test.py index a9c184cd..823aae4e 100644 --- a/tests/hypothesis/gx_test.py +++ b/tests/hypothesis/gx_test.py @@ -14,12 +14,10 @@ # along with this library; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Test gx Track and MultiTrack.""" -import datetime import typing from hypothesis import given from hypothesis import strategies as st -from hypothesis.extra.dateutil import timezones from pygeoif.hypothesis.strategies import epsg4326 from pygeoif.hypothesis.strategies import points @@ -29,12 +27,12 @@ import fastkml.types from fastkml.gx import Angle from fastkml.gx import TrackItem -from fastkml.times import KmlDateTime from tests.base import Lxml from tests.hypothesis.common import assert_repr_roundtrip from tests.hypothesis.common import assert_str_roundtrip from tests.hypothesis.common import assert_str_roundtrip_terse from tests.hypothesis.common import assert_str_roundtrip_verbose +from tests.hypothesis.strategies import kml_datetimes from tests.hypothesis.strategies import nc_name track_items = st.builds( @@ -51,15 +49,7 @@ ), ), coord=points(srs=epsg4326), - when=st.builds( - KmlDateTime, - dt=st.datetimes( - allow_imaginary=False, - timezones=timezones(), - min_value=datetime.datetime(2000, 1, 1), # noqa: DTZ001 - max_value=datetime.datetime(2050, 1, 1), # noqa: DTZ001 - ), - ), + when=kml_datetimes(), ) diff --git a/tests/hypothesis/strategies.py b/tests/hypothesis/strategies.py index 3e154cea..8f6cd19c 100644 --- a/tests/hypothesis/strategies.py +++ b/tests/hypothesis/strategies.py @@ -15,6 +15,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Custom hypothesis strategies for testing.""" +import datetime import re import string from functools import partial @@ -22,6 +23,10 @@ from urllib.parse import urlencode from hypothesis import strategies as st +from hypothesis.extra.dateutil import timezones + +import fastkml.enums +from fastkml.times import KmlDateTime ID_TEXT: Final = string.ascii_letters + string.digits + ".-_" nc_name = partial( @@ -48,6 +53,27 @@ alphabet=st.characters(min_codepoint=1, blacklist_categories=("Cc", "Cs")), ) +kml_datetimes = partial( + st.builds, + KmlDateTime, + dt=st.one_of( + st.dates( + min_value=datetime.date(2000, 1, 1), + max_value=datetime.date(2050, 1, 1), + ), + st.datetimes( + allow_imaginary=False, + timezones=timezones(), + min_value=datetime.datetime(2000, 1, 1), # noqa: DTZ001 + max_value=datetime.datetime(2050, 1, 1), # noqa: DTZ001 + ), + ), + resolution=st.one_of( + st.none(), + st.one_of(st.none(), st.sampled_from(fastkml.enums.DateTimeResolution)), + ), +) + @st.composite def query_strings(draw: st.DrawFn) -> str: diff --git a/tests/hypothesis/times_test.py b/tests/hypothesis/times_test.py new file mode 100644 index 00000000..ccfa4f42 --- /dev/null +++ b/tests/hypothesis/times_test.py @@ -0,0 +1,76 @@ +# Copyright (C) 2024 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 +""" +Property-based tests for the times module. + +These tests use the hypothesis library to generate random input for the +functions under test. The tests are run with pytest. +""" +import typing + +from hypothesis import given +from hypothesis import strategies as st + +import fastkml +import fastkml.enums +import fastkml.times +from tests.base import Lxml +from tests.hypothesis.common import assert_repr_roundtrip +from tests.hypothesis.common import assert_str_roundtrip +from tests.hypothesis.common import assert_str_roundtrip_terse +from tests.hypothesis.common import assert_str_roundtrip_verbose +from tests.hypothesis.strategies import kml_datetimes +from tests.hypothesis.strategies import nc_name + + +class TestTimes(Lxml): + @given( + id=st.one_of(st.none(), nc_name()), + target_id=st.one_of(st.none(), nc_name()), + timestamp=kml_datetimes(), + ) + def test_fuzz_time_stamp( + self, + id: typing.Optional[str], + target_id: typing.Optional[str], + timestamp: typing.Optional[fastkml.times.KmlDateTime], + ) -> None: + time_stamp = fastkml.TimeStamp(id=id, target_id=target_id, timestamp=timestamp) + + assert_repr_roundtrip(time_stamp) + assert_str_roundtrip(time_stamp) + assert_str_roundtrip_terse(time_stamp) + assert_str_roundtrip_verbose(time_stamp) + + @given( + id=st.one_of(st.none(), nc_name()), + target_id=st.one_of(st.none(), nc_name()), + begin=kml_datetimes(), + end=kml_datetimes(), + ) + def test_fuzz_time_span( + self, + id: typing.Optional[str], + target_id: typing.Optional[str], + begin: typing.Optional[fastkml.times.KmlDateTime], + end: typing.Optional[fastkml.times.KmlDateTime], + ) -> None: + time_span = fastkml.TimeSpan(id=id, target_id=target_id, begin=begin, end=end) + + assert_repr_roundtrip(time_span) + assert_str_roundtrip(time_span) + assert_str_roundtrip_terse(time_span) + assert_str_roundtrip_verbose(time_span) diff --git a/tests/times_test.py b/tests/times_test.py index 1876a003..201ba02d 100644 --- a/tests/times_test.py +++ b/tests/times_test.py @@ -102,7 +102,7 @@ def test_parse_year(self) -> None: assert dt assert dt.resolution == DateTimeResolution.year - assert dt.dt == datetime.datetime(2000, 1, 1, tzinfo=tzutc()) + assert dt.dt == datetime.date(2000, 1, 1) def test_parse_year_0(self) -> None: with pytest.raises( @@ -116,14 +116,14 @@ def test_parse_year_month(self) -> None: assert dt assert dt.resolution == DateTimeResolution.year_month - assert dt.dt == datetime.datetime(2000, 3, 1, tzinfo=tzutc()) + assert dt.dt == datetime.date(2000, 3, 1) def test_parse_year_month_no_dash(self) -> None: dt = KmlDateTime.parse("200004") assert dt assert dt.resolution == DateTimeResolution.year_month - assert dt.dt == datetime.datetime(2000, 4, 1, tzinfo=tzutc()) + assert dt.dt == datetime.date(2000, 4, 1) def test_parse_year_month_0(self) -> None: with pytest.raises(ValueError, match="month must be in 1..12"): @@ -138,14 +138,14 @@ def test_parse_year_month_day(self) -> None: assert dt assert dt.resolution == DateTimeResolution.date - assert dt.dt == datetime.datetime(2000, 3, 1, tzinfo=tzutc()) + assert dt.dt == datetime.date(2000, 3, 1) def test_parse_year_month_day_no_dash(self) -> None: dt = KmlDateTime.parse("20000401") assert dt assert dt.resolution == DateTimeResolution.date - assert dt.dt == datetime.datetime(2000, 4, 1, tzinfo=tzutc()) + assert dt.dt == datetime.date(2000, 4, 1) def test_parse_year_month_day_0(self) -> None: with pytest.raises( @@ -295,7 +295,7 @@ def test_read_timestamp_year(self) -> None: assert ts.timestamp assert ts.timestamp.resolution == DateTimeResolution.year - assert ts.timestamp.dt == datetime.datetime(1997, 1, 1, 0, 0, tzinfo=tzutc()) + assert ts.timestamp.dt == datetime.date(1997, 1, 1) def test_read_timestamp_year_month(self) -> None: doc = """ @@ -308,7 +308,7 @@ def test_read_timestamp_year_month(self) -> None: assert ts.timestamp assert ts.timestamp.resolution == DateTimeResolution.year_month - assert ts.timestamp.dt == datetime.datetime(1997, 7, 1, 0, 0, tzinfo=tzutc()) + assert ts.timestamp.dt == datetime.date(1997, 7, 1) def test_read_timestamp_ym_no_hyphen(self) -> None: doc = """ @@ -321,7 +321,7 @@ def test_read_timestamp_ym_no_hyphen(self) -> None: assert ts.timestamp assert ts.timestamp.resolution == DateTimeResolution.year_month - assert ts.timestamp.dt == datetime.datetime(1998, 8, 1, 0, 0, tzinfo=tzutc()) + assert ts.timestamp.dt == datetime.date(1998, 8, 1) def test_read_timestamp_ymd(self) -> None: doc = """ @@ -334,7 +334,7 @@ def test_read_timestamp_ymd(self) -> None: assert ts.timestamp assert ts.timestamp.resolution == DateTimeResolution.date - assert ts.timestamp.dt == datetime.datetime(1997, 7, 16, 0, 0, tzinfo=tzutc()) + assert ts.timestamp.dt == datetime.date(1997, 7, 16) def test_read_timestamp_utc(self) -> None: # dateTime (YYYY-MM-DDThh:mm:ssZ) @@ -393,7 +393,7 @@ def test_read_timespan(self) -> None: assert ts.begin assert ts.begin.resolution == DateTimeResolution.date - assert ts.begin.dt == datetime.datetime(1876, 8, 1, 0, 0, tzinfo=tzutc()) + assert ts.begin.dt == datetime.date(1876, 8, 1) assert ts.end assert ts.end.resolution == DateTimeResolution.datetime assert ts.end.dt == datetime.datetime(1997, 7, 16, 7, 30, 15, tzinfo=tzutc()) @@ -415,7 +415,7 @@ def test_feature_fromstring(self) -> None: assert d.time_stamp is None assert d.begin - assert d.begin.dt == datetime.datetime(1876, 8, 1, 0, 0, tzinfo=tzutc()) + assert d.begin.dt == datetime.date(1876, 8, 1) assert d.end assert d.end.dt == datetime.datetime(1997, 7, 16, 7, 30, 15, tzinfo=tzutc()) From 44978db5a03501cf6c93515777cb963c35851ea1 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Tue, 5 Nov 2024 19:29:13 +0000 Subject: [PATCH 02/18] enhance hypothesis tests: Allow None values for timestamp, begin, and end in time tests --- tests/hypothesis/times_test.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/hypothesis/times_test.py b/tests/hypothesis/times_test.py index ccfa4f42..b7b1ab71 100644 --- a/tests/hypothesis/times_test.py +++ b/tests/hypothesis/times_test.py @@ -40,7 +40,7 @@ class TestTimes(Lxml): @given( id=st.one_of(st.none(), nc_name()), target_id=st.one_of(st.none(), nc_name()), - timestamp=kml_datetimes(), + timestamp=st.one_of(st.none(), kml_datetimes()), ) def test_fuzz_time_stamp( self, @@ -58,8 +58,8 @@ def test_fuzz_time_stamp( @given( id=st.one_of(st.none(), nc_name()), target_id=st.one_of(st.none(), nc_name()), - begin=kml_datetimes(), - end=kml_datetimes(), + begin=st.one_of(st.none(), kml_datetimes()), + end=st.one_of(st.none(), kml_datetimes()), ) def test_fuzz_time_span( self, From 98decb426e19bd1c9d2bff5fe7c3cd072add5e22 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Wed, 6 Nov 2024 09:10:00 +0000 Subject: [PATCH 03/18] add edit on github link to documentation --- docs/conf.py | 8 ++++++++ fastkml/about.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 63233c5c..53caa139 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -176,6 +176,14 @@ # If true, links to the reST sources are added to the pages. # html_show_sourcelink = True +html_context = { + "display_github": True, # Integrate GitHub + "github_user": "cleder", # Username + "github_repo": "fastkml", # Repo name + "github_version": "main", # Version + "conf_py_path": "/docs/", # Path in the checkout to the docs root +} + # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # html_show_sphinx = True diff --git a/fastkml/about.py b/fastkml/about.py index c3030e31..a38dcd65 100644 --- a/fastkml/about.py +++ b/fastkml/about.py @@ -18,4 +18,4 @@ The only purpose of this module is to provide a version number for the package. """ -__version__ = "1.0.0b1" +__version__ = "1.0.0b2" From 148a3f5dfb0a5c87501cadc1139cd958b2035b9e Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Wed, 6 Nov 2024 09:29:42 +0000 Subject: [PATCH 04/18] update codecov.io badge --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index d96255f5..7412e8ef 100644 --- a/README.rst +++ b/README.rst @@ -28,7 +28,7 @@ Fastkml is continually tested :target: https://github.com/cleder/fastkml/actions/workflows/run-all-tests.yml :alt: Test -.. |cov| image:: http://codecov.io/github/cleder/fastkml/coverage.svg?branch=main +.. |cov| image:: https://codecov.io/gh/cleder/fastkml/branch/main/graph/badge.svg?token=VIuhPHq0ow :target: http://codecov.io/github/cleder/fastkml?branch=main :alt: codecov.io From 1eea2da2767bf23797b7ce3cdcba13ed00fea1a9 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Wed, 6 Nov 2024 13:04:22 +0000 Subject: [PATCH 05/18] Update pre-commit configuration, enhance documentation, and refine module exports --- .pre-commit-config.yaml | 15 +++++------ README.rst | 4 ++- docs/configuration.rst | 4 ++- docs/fastkml.rst | 60 ++++++++++++++++++++++------------------- docs/modules.rst | 1 - fastkml/about.py | 3 +++ fastkml/utils.py | 2 +- pyproject.toml | 6 +++++ 8 files changed, 55 insertions(+), 40 deletions(-) delete mode 100644 docs/modules.rst diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b2fcf01b..5473af11 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,11 @@ --- +default_language_version: + python: python3.12 repos: + - repo: https://github.com/pre-commit-ci/pre-commit-ci-config + rev: v1.6.1 + hooks: + - id: check-pre-commit-ci-config - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: @@ -36,18 +42,11 @@ repos: rev: v0.3.1 hooks: - id: absolufy-imports - - repo: https://github.com/psf/black - rev: 24.10.0 - hooks: - - id: black - - repo: https://github.com/pycqa/isort - rev: 5.13.2 - hooks: - - id: isort - repo: https://github.com/astral-sh/ruff-pre-commit rev: 'v0.7.2' hooks: - id: ruff + - id: ruff-format - repo: https://github.com/PyCQA/flake8 rev: 7.1.1 hooks: diff --git a/README.rst b/README.rst index 7412e8ef..b52bcde9 100644 --- a/README.rst +++ b/README.rst @@ -108,7 +108,9 @@ Requirements Optional --------- -* lxml_:: +* lxml_: + +.. code-block:: bash pip install --pre "fastkml[lxml]" diff --git a/docs/configuration.rst b/docs/configuration.rst index a0dbf585..a006a548 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -11,7 +11,9 @@ as its parser, but you can change this by setting the implementation. E.g. if you have lxml installed, but you want to use the -standard ``xml.etree.ElementTree``, you can do this:: +standard ``xml.etree.ElementTree``, you can do this: + +.. code-block:: pycon >>> import fastkml.config >>> import xml.etree.ElementTree diff --git a/docs/fastkml.rst b/docs/fastkml.rst index 67c325ed..e752b965 100644 --- a/docs/fastkml.rst +++ b/docs/fastkml.rst @@ -2,15 +2,10 @@ Reference Guide =============== -.. toctree:: - :maxdepth: 4 - - - .. automodule:: fastkml -fastkml.about module +fastkml.about -------------------- .. automodule:: fastkml.about @@ -18,7 +13,7 @@ fastkml.about module :undoc-members: :show-inheritance: -fastkml.atom module +fastkml.atom ------------------- .. automodule:: fastkml.atom @@ -26,7 +21,7 @@ fastkml.atom module :undoc-members: :show-inheritance: -fastkml.base module +fastkml.base ------------------- .. automodule:: fastkml.base @@ -34,7 +29,7 @@ fastkml.base module :undoc-members: :show-inheritance: -fastkml.config module +fastkml.config --------------------- .. automodule:: fastkml.config @@ -42,7 +37,7 @@ fastkml.config module :undoc-members: :show-inheritance: -fastkml.containers module +fastkml.containers ------------------------- .. automodule:: fastkml.containers @@ -50,7 +45,7 @@ fastkml.containers module :undoc-members: :show-inheritance: -fastkml.data module +fastkml.data ------------------- .. automodule:: fastkml.data @@ -58,7 +53,7 @@ fastkml.data module :undoc-members: :show-inheritance: -fastkml.enums module +fastkml.enums -------------------- .. automodule:: fastkml.enums @@ -66,7 +61,7 @@ fastkml.enums module :undoc-members: :show-inheritance: -fastkml.exceptions module +fastkml.exceptions ------------------------- .. automodule:: fastkml.exceptions @@ -74,7 +69,7 @@ fastkml.exceptions module :undoc-members: :show-inheritance: -fastkml.features module +fastkml.features ----------------------- .. automodule:: fastkml.features @@ -82,7 +77,7 @@ fastkml.features module :undoc-members: :show-inheritance: -fastkml.geometry module +fastkml.geometry ----------------------- .. automodule:: fastkml.geometry @@ -90,7 +85,7 @@ fastkml.geometry module :undoc-members: :show-inheritance: -fastkml.gx module +fastkml.gx ----------------- .. automodule:: fastkml.gx @@ -98,7 +93,7 @@ fastkml.gx module :undoc-members: :show-inheritance: -fastkml.helpers module +fastkml.helpers ---------------------- .. automodule:: fastkml.helpers @@ -106,7 +101,7 @@ fastkml.helpers module :undoc-members: :show-inheritance: -fastkml.kml module +fastkml.kml ------------------ .. automodule:: fastkml.kml @@ -114,7 +109,7 @@ fastkml.kml module :undoc-members: :show-inheritance: -fastkml.kml\_base module +fastkml.kml\_base ------------------------ .. automodule:: fastkml.kml_base @@ -122,7 +117,7 @@ fastkml.kml\_base module :undoc-members: :show-inheritance: -fastkml.links module +fastkml.links -------------------- .. automodule:: fastkml.links @@ -130,7 +125,7 @@ fastkml.links module :undoc-members: :show-inheritance: -fastkml.mixins module +fastkml.mixins --------------------- .. automodule:: fastkml.mixins @@ -138,7 +133,7 @@ fastkml.mixins module :undoc-members: :show-inheritance: -fastkml.overlays module +fastkml.overlays ----------------------- .. automodule:: fastkml.overlays @@ -146,7 +141,7 @@ fastkml.overlays module :undoc-members: :show-inheritance: -fastkml.registry module +fastkml.registry ----------------------- .. automodule:: fastkml.registry @@ -154,7 +149,7 @@ fastkml.registry module :undoc-members: :show-inheritance: -fastkml.styles module +fastkml.styles --------------------- .. automodule:: fastkml.styles @@ -162,7 +157,7 @@ fastkml.styles module :undoc-members: :show-inheritance: -fastkml.times module +fastkml.times -------------------- .. automodule:: fastkml.times @@ -170,7 +165,7 @@ fastkml.times module :undoc-members: :show-inheritance: -fastkml.types module +fastkml.types -------------------- .. automodule:: fastkml.types @@ -178,7 +173,7 @@ fastkml.types module :undoc-members: :show-inheritance: -fastkml.utils module +fastkml.utils -------------------- .. automodule:: fastkml.utils @@ -186,7 +181,16 @@ fastkml.utils module :undoc-members: :show-inheritance: -fastkml.views module +fastkml.validator +-------------------- + +.. automodule:: fastkml.validator + :members: + :undoc-members: + :show-inheritance: + + +fastkml.views -------------------- .. automodule:: fastkml.views diff --git a/docs/modules.rst b/docs/modules.rst deleted file mode 100644 index d3cafeb8..00000000 --- a/docs/modules.rst +++ /dev/null @@ -1 +0,0 @@ - fastkml diff --git a/fastkml/about.py b/fastkml/about.py index a38dcd65..c99d0c99 100644 --- a/fastkml/about.py +++ b/fastkml/about.py @@ -18,4 +18,7 @@ The only purpose of this module is to provide a version number for the package. """ + __version__ = "1.0.0b2" + +__all__ = ["__version__"] diff --git a/fastkml/utils.py b/fastkml/utils.py index 2851cb5c..7616c610 100644 --- a/fastkml/utils.py +++ b/fastkml/utils.py @@ -7,7 +7,7 @@ from typing import Type from typing import Union -__all__ = ["find_all", "has_attribute_values"] +__all__ = ["find", "find_all", "has_attribute_values"] def has_attribute_values(obj: object, **kwargs: Any) -> bool: diff --git a/pyproject.toml b/pyproject.toml index dc3c71e3..f45254c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -179,6 +179,12 @@ include = [ "fastkml", ] +[tool.rstcheck] +ignore_directives = [ + "automodule", +] +report_level = "WARNING" + [tool.ruff] fix = true target-version = "py38" From 839074a212805505c2d84efaf99b7dfafaf5b2a5 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Wed, 6 Nov 2024 13:46:16 +0000 Subject: [PATCH 06/18] fix indentation --- .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 5473af11..ccb8bba9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ --- default_language_version: - python: python3.12 + python: python3.12 repos: - repo: https://github.com/pre-commit-ci/pre-commit-ci-config rev: v1.6.1 From 274fc5437a1a2c7e3f3fe0f27dcfa975f8554c0d Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Thu, 7 Nov 2024 11:22:36 +0000 Subject: [PATCH 07/18] Add SonarCloud integration with workflow and project properties --- .github/workflows/sonarcloud.yml | 20 ++++++++++++++++++++ sonar-project.properties | 13 +++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 .github/workflows/sonarcloud.yml create mode 100644 sonar-project.properties diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 00000000..c22b0e71 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,20 @@ +name: SonarBuild +on: + push: + branches: + - develop + pull_request: + types: [opened, synchronize, reopened] +jobs: + sonarcloud: + name: SonarCloud + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..1a93593a --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,13 @@ +sonar.projectKey=cleder_fastkml +sonar.organization=cleder-github + +# This is the name and version displayed in the SonarCloud UI. +sonar.projectName=fastkml +sonar.projectVersion=1.0 + + +# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. +sonar.sources=. + +# Encoding of the source code. Default is default system encoding +sonar.sourceEncoding=UTF-8 From 10f44fe59ebe09e7ebe1ab9153d5de2da18dc004 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Thu, 7 Nov 2024 18:28:35 +0000 Subject: [PATCH 08/18] Remove verbose flag from doctest command in CI workflow --- .github/workflows/run-all-tests.yml | 2 +- docs/create_kml_files.rst | 305 ++++++++++++++-------------- docs/working_with_kml.rst | 8 + 3 files changed, 162 insertions(+), 153 deletions(-) diff --git a/.github/workflows/run-all-tests.yml b/.github/workflows/run-all-tests.yml index fa04e128..4e47acd1 100644 --- a/.github/workflows/run-all-tests.yml +++ b/.github/workflows/run-all-tests.yml @@ -70,7 +70,7 @@ jobs: pip install -e ".[tests, lxml, docs]" - name: test the pythoncode in the documentation run: | - python -m doctest docs/*.rst -v + python -m doctest docs/*.rst - name: Run the pythoncode in the examples run: | python examples/read_kml.py diff --git a/docs/create_kml_files.rst b/docs/create_kml_files.rst index 5fff34db..0b6938a6 100644 --- a/docs/create_kml_files.rst +++ b/docs/create_kml_files.rst @@ -15,43 +15,43 @@ Our World in Data, and the Small scale data (1:110m) shapefile from First we import the necessary modules: -.. code-block:: python - - import csv - import pathlib - import random - - import shapefile - from pygeoif.factories import force_3d - from pygeoif.factories import shape - - import fastkml - import fastkml.containers - import fastkml.features - import fastkml.styles - from fastkml.enums import AltitudeMode - from fastkml.geometry import create_kml_geometry +.. code-block:: pycon + + >>> import csv + >>> import pathlib + >>> import random + >>> import shapefile + >>> from pygeoif.factories import force_3d + >>> from pygeoif.factories import shape + >>> import fastkml + >>> import fastkml.containers + >>> import fastkml.features + >>> import fastkml.styles + >>> from fastkml.enums import AltitudeMode + >>> from fastkml.geometry import create_kml_geometry Read the shapefile: -.. code-block:: python +.. code-block:: pycon - shp = shapefile.Reader("ne_110m_admin_0_countries.shp") + >>> shp = shapefile.Reader("examples/ne_110m_admin_0_countries.shp") Read the CSV file and store the CO2 data for 2020: -.. code-block:: python +.. code-block:: pycon - co2_csv = pathlib.Path("owid-co2-data.csv") - co2_data = {} - with co2_csv.open() as csvfile: - reader = csv.DictReader(csvfile) - for row in reader: - if row["year"] == "2020": - co2_data[row["iso_code"]] = ( - float(row["co2_per_capita"]) if row["co2_per_capita"] else 0 - ) + >>> shp = shapefile.Reader("examples/ne_110m_admin_0_countries.shp") + >>> co2_csv = pathlib.Path("examples/owid-co2-data.csv") + >>> co2_data = {} + >>> with co2_csv.open() as csvfile: + ... reader = csv.DictReader(csvfile) + ... for row in reader: + ... if row["year"] == "2020": + ... co2_data[row["iso_code"]] = ( + ... float(row["co2_per_capita"]) if row["co2_per_capita"] else 0 + ... ) + ... We prepare the styles and placemarks for the KML file, using random colors for each @@ -59,50 +59,52 @@ country and the CO2 emissions as the height of the geometry. The shapefile offer a handy ``__geo_interface__`` attribute that we can use to iterate over the features, just like we would with a ``GeoJSON`` object, and extract the necessary information: -.. code-block:: python - - placemarks = [] - for feature in shp.__geo_interface__["features"]: - iso3_code = feature["properties"]["ADM0_A3"] - geometry = shape(feature["geometry"]) - co2_emission = co2_data.get(iso3_code, 0) - geometry = force_3d(geometry, co2_emission * 100_000) - kml_geometry = create_kml_geometry( - geometry, - extrude=True, - altitude_mode=AltitudeMode.relative_to_ground, - ) - color = random.randint(0, 0xFFFFFF) - style = fastkml.styles.Style( - id=iso3_code, - styles=[ - fastkml.styles.LineStyle(color=f"33{color:06X}", width=2), - fastkml.styles.PolyStyle( - color=f"88{color:06X}", - fill=True, - outline=True, - ), - ], - ) - placemark = fastkml.features.Placemark( - name=feature["properties"]["NAME"], - description=feature["properties"]["FORMAL_EN"], - kml_geometry=kml_geometry, - styles=[style], - ) - placemarks.append(placemark) +.. code-block:: pycon + + >>> placemarks = [] + >>> for feature in shp.__geo_interface__["features"]: + ... iso3_code = feature["properties"]["ADM0_A3"] + ... geometry = shape(feature["geometry"]) + ... co2_emission = co2_data.get(iso3_code, 0) + ... geometry = force_3d(geometry, co2_emission * 100_000) + ... kml_geometry = create_kml_geometry( + ... geometry, + ... extrude=True, + ... altitude_mode=AltitudeMode.relative_to_ground, + ... ) + ... color = random.randint(0, 0xFFFFFF) + ... style = fastkml.styles.Style( + ... id=iso3_code, + ... styles=[ + ... fastkml.styles.LineStyle(color=f"33{color:06X}", width=2), + ... fastkml.styles.PolyStyle( + ... color=f"88{color:06X}", + ... fill=True, + ... outline=True, + ... ), + ... ], + ... ) + ... placemark = fastkml.features.Placemark( + ... name=feature["properties"]["NAME"], + ... description=feature["properties"]["FORMAL_EN"], + ... kml_geometry=kml_geometry, + ... styles=[style], + ... ) + ... placemarks.append(placemark) + ... Finally, we create the KML object and write it to a file: -.. code-block:: python - - document = fastkml.containers.Document(features=placemarks, styles=styles) - kml = fastkml.KML(features=[document]) +.. code-block:: pycon - outfile = pathlib.Path("co2_per_capita_2020.kml") - with outfile.open("w") as f: - f.write(kml.to_string(prettyprint=True, precision=6)) + >>> document = fastkml.containers.Document(features=placemarks) + >>> kml = fastkml.KML(features=[document]) + >>> outfile = pathlib.Path("co2_per_capita_2020.kml") + >>> with outfile.open("w") as f: + ... f.write(kml.to_string(prettyprint=True, precision=3)) # doctest: +ELLIPSIS + ... + 4... The resulting KML file can be opened in Google Earth or any other KML viewer. @@ -122,38 +124,37 @@ create a KML file that shows the CO2 emissions accumulating from 1995 to 2022. First we import the necessary modules: -.. code-block:: python - - import csv - import pathlib - import random - import datetime - import shapefile - from pygeoif.factories import force_3d - from pygeoif.factories import shape - - import fastkml - import fastkml.containers - import fastkml.features - import fastkml.styles - import fastkml.times - from fastkml.enums import AltitudeMode, DateTimeResolution - from fastkml.geometry import create_kml_geometry +.. code-block:: pycon + + >>> import csv + >>> import pathlib + >>> import random + >>> import datetime + >>> import shapefile + >>> from pygeoif.factories import force_3d + >>> from pygeoif.factories import shape + >>> import fastkml + >>> import fastkml.containers + >>> import fastkml.features + >>> import fastkml.styles + >>> import fastkml.times + >>> from fastkml.enums import AltitudeMode, DateTimeResolution + >>> from fastkml.geometry import create_kml_geometry Read the shapefile, the CSV file and store the CO2 data for each year: -.. code-block:: python +.. code-block:: pycon - shp = shapefile.Reader("ne_110m_admin_0_countries.shp") - co2_csv = pathlib.Path("owid-co2-data.csv") - co2_pa = {str(i): {} for i in range(1995, 2023)} - with co2_csv.open() as csvfile: - reader = csv.DictReader(csvfile) - for row in reader: - if row["year"] >= "1995": - co2_pa[row["year"]][row["iso_code"]] = ( - float(row["co2_per_capita"]) if row["co2_per_capita"] else 0 - ) + >>> co2_csv = pathlib.Path("examples/owid-co2-data.csv") + >>> co2_pa = {str(i): {} for i in range(1995, 2023)} + >>> with co2_csv.open() as csvfile: + ... reader = csv.DictReader(csvfile) + ... for row in reader: + ... if row["year"] >= "1995": + ... co2_pa[row["year"]][row["iso_code"]] = ( + ... float(row["co2_per_capita"]) if row["co2_per_capita"] else 0 + ... ) + ... @@ -163,67 +164,67 @@ We will also create a style for each country, which we store at the document lev prevent creating duplicate styles. Each placemark will have a time-span that covers the whole year: -.. code-block:: python - - styles = [] - folders = [] - for feature in shp.__geo_interface__["features"]: - iso3_code = feature["properties"]["ADM0_A3"] - geometry = shape(feature["geometry"]) - color = random.randint(0, 0xFFFFFF) - styles.append( - fastkml.styles.Style( - id=iso3_code, - styles=[ - fastkml.styles.LineStyle(color=f"33{color:06X}", width=2), - fastkml.styles.PolyStyle( - color=f"88{color:06X}", - fill=True, - outline=True, - ), - ], - ), - ) - style_url = fastkml.styles.StyleUrl(url=f"#{iso3_code}") - folder = fastkml.containers.Folder(name=feature["properties"]["NAME"]) - co2_growth = 0 - for year in range(1995, 2023): - co2_year = co2_pa[str(year)].get(iso3_code, 0) - co2_growth += co2_year - - kml_geometry = create_kml_geometry( - force_3d(geometry, co2_growth * 5_000), - extrude=True, - altitude_mode=AltitudeMode.relative_to_ground, - ) - timespan = fastkml.times.TimeSpan( - begin=fastkml.times.KmlDateTime( - datetime.date(year, 1, 1), resolution=DateTimeResolution.year_month - ), - end=fastkml.times.KmlDateTime( - datetime.date(year, 12, 31), resolution=DateTimeResolution.year_month - ), - ) - placemark = fastkml.features.Placemark( - name=f"{feature['properties']['NAME']} - {year}", - description=feature["properties"]["FORMAL_EN"], - kml_geometry=kml_geometry, - style_url=style_url, - times=timespan, - ) - folder.features.append(placemark) - folders.append(folder) +.. code-block:: pycon + +>>> styles = [] +>>> folders = [] +>>> for feature in shp.__geo_interface__["features"]: +... iso3_code = feature["properties"]["ADM0_A3"] +... geometry = shape(feature["geometry"]) +... color = random.randint(0, 0xFFFFFF) +... styles.append( +... fastkml.styles.Style( +... id=iso3_code, +... styles=[ +... fastkml.styles.LineStyle(color=f"33{color:06X}", width=2), +... fastkml.styles.PolyStyle( +... color=f"88{color:06X}", +... fill=True, +... outline=True, +... ), +... ], +... ), +... ) +... style_url = fastkml.styles.StyleUrl(url=f"#{iso3_code}") +... folder = fastkml.containers.Folder(name=feature["properties"]["NAME"]) +... co2_growth = 0 +... for year in range(1995, 2023): +... co2_year = co2_pa[str(year)].get(iso3_code, 0) +... co2_growth += co2_year +... kml_geometry = create_kml_geometry( +... force_3d(geometry, co2_growth * 5_000), +... extrude=True, +... altitude_mode=AltitudeMode.relative_to_ground, +... ) +... timespan = fastkml.times.TimeSpan( +... begin=fastkml.times.KmlDateTime( +... datetime.date(year, 1, 1), resolution=DateTimeResolution.year_month +... ), +... end=fastkml.times.KmlDateTime( +... datetime.date(year, 12, 31), resolution=DateTimeResolution.year_month +... ), +... ) +... placemark = fastkml.features.Placemark( +... name=f"{feature['properties']['NAME']} - {year}", +... description=feature["properties"]["FORMAL_EN"], +... kml_geometry=kml_geometry, +... style_url=style_url, +... times=timespan, +... ) +... folder.features.append(placemark) +... folders.append(folder) Finally, we create the KML object and write it to a file: -.. code-block:: python - - document = fastkml.containers.Document(features=folders, styles=styles) - kml = fastkml.KML(features=[document]) +.. code-block:: pycon - outfile = pathlib.Path("co2_growth_1995_2022.kml") - with outfile.open("w") as f: - f.write(kml.to_string(prettyprint=True, precision=3)) + >>> document = fastkml.containers.Document(features=folders, styles=styles) + >>> kml = fastkml.KML(features=[document]) + >>> outfile = pathlib.Path("co2_growth_1995_2022.kml") + >>> with outfile.open("w") as f: + ... f.write(kml.to_string(prettyprint=True, precision=3)) # doctest: +ELLIPSIS + ... + 1... You can open the resulting KML file in Google Earth Desktop and use the time slider to diff --git a/docs/working_with_kml.rst b/docs/working_with_kml.rst index c227bb15..0150953c 100644 --- a/docs/working_with_kml.rst +++ b/docs/working_with_kml.rst @@ -29,6 +29,12 @@ type and returns an iterator of all matching elements found in the document tree ... POINT Z (-123.93563168 49.16716103 5.0) POLYGON Z ((-123.940449937288 49.16927524669021 17.0, ... + +We could also search for all Points, which will also return the Points inside the +``MultiGeometries``. + +.. code-block:: pycon + >>> pts = list(find_all(k, of_type=Point)) >>> for point in pts: ... print(point.geometry) @@ -75,6 +81,8 @@ For more targeted searches, we can combine multiple search criteria: Point(-123.93563168, 49.16716103, 5.0) >>> pm.style_url.url '#khStyle712' + >>> pm.name + 'HBC Bastion' Extending FastKML From 53aef73548f3892091d6c4a7ae0437b80c0d6548 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Thu, 7 Nov 2024 22:54:21 +0000 Subject: [PATCH 09/18] add hypothesis tests for views --- fastkml/views.py | 91 +++----- tests/hypothesis/geometry_test.py | 6 +- tests/hypothesis/multi_geometry_test.py | 8 +- tests/hypothesis/views_test.py | 285 ++++++++++++++++++++++++ tests/repr_eq_test.py | 2 - tests/views_test.py | 51 ----- 6 files changed, 322 insertions(+), 121 deletions(-) create mode 100644 tests/hypothesis/views_test.py diff --git a/fastkml/views.py b/fastkml/views.py index 5e56d545..97b3ea66 100644 --- a/fastkml/views.py +++ b/fastkml/views.py @@ -18,7 +18,6 @@ from typing import Any from typing import Dict from typing import Optional -from typing import Union from fastkml import config from fastkml.base import _XMLObject @@ -35,8 +34,8 @@ from fastkml.mixins import TimeMixin from fastkml.registry import RegistryItem from fastkml.registry import registry -from fastkml.times import TimeSpan -from fastkml.times import TimeStamp + +__all__ = ["Camera", "LatLonAltBox", "Lod", "LookAt", "Region"] logger = logging.getLogger(__name__) @@ -100,7 +99,6 @@ def __init__( heading: Optional[float] = None, tilt: Optional[float] = None, altitude_mode: Optional[AltitudeMode] = None, - time_primitive: Union[TimeSpan, TimeStamp, None] = None, **kwargs: Any, ) -> None: """ @@ -128,8 +126,6 @@ def __init__( The tilt angle of the view. altitude_mode : Optional[AltitudeMode] The altitude mode of the view. - time_primitive : Union[TimeSpan, TimeStamp, None] - The time primitive associated with the view. kwargs : Any Additional keyword arguments. @@ -151,7 +147,6 @@ def __init__( self.heading = heading self.tilt = tilt self.altitude_mode = altitude_mode - self.times = time_primitive def __repr__(self) -> str: """Create a string (c)representation for _AbstractView.""" @@ -167,7 +162,6 @@ def __repr__(self) -> str: f"heading={self.heading!r}, " f"tilt={self.tilt!r}, " f"altitude_mode={self.altitude_mode}, " - f"time_primitive={self.times!r}, " f"**{self._get_splat()!r}," ")" ) @@ -233,29 +227,6 @@ def __repr__(self) -> str: default=0.0, ), ) -registry.register( - _AbstractView, - RegistryItem( - ns_ids=("kml",), - attr_name="altitude_mode", - node_name="altitudeMode", - classes=(AltitudeMode,), - get_kwarg=subelement_enum_kwarg, - set_element=enum_subelement, - default=AltitudeMode.clamp_to_ground, - ), -) -registry.register( - _AbstractView, - RegistryItem( - ns_ids=("kml",), - attr_name="time_primitive", - node_name="TimeStamp", - classes=(TimeSpan, TimeStamp), - get_kwarg=xml_subelement_kwarg, - set_element=xml_subelement, - ), -) class Camera(_AbstractView): @@ -300,7 +271,6 @@ def __init__( tilt: Optional[float] = None, roll: Optional[float] = None, altitude_mode: Optional[AltitudeMode] = None, - time_primitive: Union[TimeSpan, TimeStamp, None] = None, **kwargs: Any, ) -> None: """ @@ -319,8 +289,6 @@ def __init__( tilt (Optional[float]): The tilt of the view. roll (Optional[float]): The roll of the view. altitude_mode (AltitudeMode): The altitude mode of the view. - time_primitive (Union[TimeSpan, TimeStamp, None]): The time primitive of the - view. **kwargs (Any): Additional keyword arguments. Returns: @@ -339,7 +307,6 @@ def __init__( heading=heading, tilt=tilt, altitude_mode=altitude_mode, - time_primitive=time_primitive, **kwargs, ) self.roll = roll @@ -359,7 +326,6 @@ def __repr__(self) -> str: f"tilt={self.tilt!r}, " f"roll={self.roll!r}, " f"altitude_mode={self.altitude_mode}, " - f"time_primitive={self.times!r}, " f"**{self._get_splat()!r}," ")" ) @@ -377,6 +343,18 @@ def __repr__(self) -> str: default=0.0, ), ) +registry.register( + Camera, + RegistryItem( + ns_ids=("kml", "gx", ""), + attr_name="altitude_mode", + node_name="altitudeMode", + classes=(AltitudeMode,), + get_kwarg=subelement_enum_kwarg, + set_element=enum_subelement, + default=AltitudeMode.clamp_to_ground, + ), +) class LookAt(_AbstractView): @@ -407,7 +385,6 @@ def __init__( tilt: Optional[float] = None, range: Optional[float] = None, altitude_mode: Optional[AltitudeMode] = None, - time_primitive: Union[TimeSpan, TimeStamp, None] = None, **kwargs: Any, ) -> None: """ @@ -427,7 +404,6 @@ def __init__( range (Optional[float]): The range value. altitude_mode (AltitudeMode): The altitude mode. Defaults to AltitudeMode.relative_to_ground. - time_primitive (Union[TimeSpan, TimeStamp, None]): The time primitive. **kwargs (Any): Additional keyword arguments. Returns: @@ -446,7 +422,6 @@ def __init__( heading=heading, tilt=tilt, altitude_mode=altitude_mode, - time_primitive=time_primitive, **kwargs, ) self.range = range @@ -466,7 +441,6 @@ def __repr__(self) -> str: f"tilt={self.tilt!r}, " f"range={self.range!r}, " f"altitude_mode={self.altitude_mode}, " - f"time_primitive={self.times!r}, " f"**{self._get_splat()!r}," ")" ) @@ -483,6 +457,18 @@ def __repr__(self) -> str: set_element=float_subelement, ), ) +registry.register( + LookAt, + RegistryItem( + ns_ids=("kml", "gx", ""), + attr_name="altitude_mode", + node_name="altitudeMode", + classes=(AltitudeMode,), + get_kwarg=subelement_enum_kwarg, + set_element=enum_subelement, + default=AltitudeMode.clamp_to_ground, + ), +) class LatLonAltBox(_XMLObject): @@ -653,7 +639,7 @@ def __bool__(self) -> bool: registry.register( LatLonAltBox, RegistryItem( - ns_ids=("kml",), + ns_ids=("kml", "gx", ""), attr_name="altitude_mode", node_name="altitudeMode", classes=(AltitudeMode,), @@ -701,10 +687,10 @@ def __init__( ns (Optional[str]): The namespace for the view. name_spaces (Optional[Dict[str, str]]): The dictionary of namespace prefixes and URIs. - min_lod_pixels (Optional[float]): The minimum level of detail in pixels. - max_lod_pixels (Optional[float]): The maximum level of detail in pixels. - min_fade_extent (Optional[float]): The minimum fade extent in pixels. - max_fade_extent (Optional[float]): The maximum fade extent in pixels. + min_lod_pixels (Optional[int]): The minimum level of detail in pixels. + max_lod_pixels (Optional[int]): The maximum level of detail in pixels. + min_fade_extent (Optional[int]): The minimum fade extent in pixels. + max_fade_extent (Optional[int]): The maximum fade extent in pixels. **kwargs (Any): Additional keyword arguments. Returns: @@ -775,8 +761,8 @@ def __bool__(self) -> bool: attr_name="min_fade_extent", node_name="minFadeExtent", classes=(float,), - get_kwarg=subelement_float_kwarg, - set_element=float_subelement, + get_kwarg=subelement_int_kwarg, + set_element=int_subelement, default=0, ), ) @@ -787,8 +773,8 @@ def __bool__(self) -> bool: attr_name="max_fade_extent", node_name="maxFadeExtent", classes=(float,), - get_kwarg=subelement_float_kwarg, - set_element=float_subelement, + get_kwarg=subelement_int_kwarg, + set_element=int_subelement, default=0, ), ) @@ -902,10 +888,3 @@ def __bool__(self) -> bool: set_element=xml_subelement, ), ) - - -__all__ = [ - "Camera", - "LookAt", - "Region", -] diff --git a/tests/hypothesis/geometry_test.py b/tests/hypothesis/geometry_test.py index 0e85323d..22dd8d65 100644 --- a/tests/hypothesis/geometry_test.py +++ b/tests/hypothesis/geometry_test.py @@ -69,11 +69,7 @@ altitude_mode=st.one_of( st.none(), st.sampled_from( - ( - AltitudeMode.absolute, - AltitudeMode.clamp_to_ground, - AltitudeMode.relative_to_ground, - ), + AltitudeMode, ), ), ) diff --git a/tests/hypothesis/multi_geometry_test.py b/tests/hypothesis/multi_geometry_test.py index f54a56f6..e20950f3 100644 --- a/tests/hypothesis/multi_geometry_test.py +++ b/tests/hypothesis/multi_geometry_test.py @@ -65,13 +65,7 @@ tessellate=st.one_of(st.none(), st.booleans()), altitude_mode=st.one_of( st.none(), - st.sampled_from( - ( - AltitudeMode.absolute, - AltitudeMode.clamp_to_ground, - AltitudeMode.relative_to_ground, - ), - ), + st.sampled_from(AltitudeMode), ), ) diff --git a/tests/hypothesis/views_test.py b/tests/hypothesis/views_test.py new file mode 100644 index 00000000..bfdc8f8a --- /dev/null +++ b/tests/hypothesis/views_test.py @@ -0,0 +1,285 @@ +# Copyright (C) 2024 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 +"""Property-based tests for the views module.""" +import typing +from functools import partial + +from hypothesis import given +from hypothesis import strategies as st + +import fastkml +import fastkml.enums +import fastkml.views +from fastkml.views import LatLonAltBox +from fastkml.views import Lod +from tests.base import Lxml +from tests.hypothesis.common import assert_repr_roundtrip +from tests.hypothesis.common import assert_str_roundtrip +from tests.hypothesis.common import assert_str_roundtrip_terse +from tests.hypothesis.common import assert_str_roundtrip_verbose +from tests.hypothesis.strategies import nc_name + +lods = partial( + st.builds, + Lod, + min_lod_pixels=st.integers(), + max_lod_pixels=st.integers(), + min_fade_extent=st.integers(), + max_fade_extent=st.integers(), +) + +lat_lon_alt_boxes = partial( + st.builds, + LatLonAltBox, + north=st.floats(allow_nan=False, allow_infinity=False, min_value=0, max_value=90), + south=st.floats(allow_nan=False, allow_infinity=False, min_value=0, max_value=90), + east=st.floats(allow_nan=False, allow_infinity=False, min_value=0, max_value=180), + west=st.floats(allow_nan=False, allow_infinity=False, min_value=0, max_value=180), + min_altitude=st.floats(allow_nan=False, allow_infinity=False).filter( + lambda x: x != 0, + ), + max_altitude=st.floats(allow_nan=False, allow_infinity=False).filter( + lambda x: x != 0, + ), + altitude_mode=st.sampled_from(fastkml.enums.AltitudeMode), +) + +common_view = partial( + given, + id=st.one_of(st.none(), nc_name()), + target_id=st.one_of(st.none(), nc_name()), + longitude=st.one_of( + st.none(), + st.floats( + allow_nan=False, + allow_infinity=False, + min_value=-180, + max_value=180, + ).filter(lambda x: x != 0), + ), + latitude=st.one_of( + st.none(), + st.floats( + allow_nan=False, + allow_infinity=False, + min_value=-90, + max_value=90, + ).filter(lambda x: x != 0), + ), + altitude=st.one_of( + st.none(), + st.floats(allow_nan=False, allow_infinity=False).filter(lambda x: x != 0), + ), + heading=st.one_of( + st.none(), + st.floats(allow_nan=False, allow_infinity=False, min_value=0, max_value=360), + ), + tilt=st.one_of( + st.none(), + st.floats(allow_nan=False, allow_infinity=False, min_value=0, max_value=180), + ), + altitude_mode=st.one_of(st.none(), st.sampled_from(fastkml.enums.AltitudeMode)), +) + + +class TestLxml(Lxml): + + @given( + min_lod_pixels=st.one_of(st.none(), st.integers()), + max_lod_pixels=st.one_of(st.none(), st.integers()), + min_fade_extent=st.one_of(st.none(), st.integers()), + max_fade_extent=st.one_of(st.none(), st.integers()), + ) + def test_fuzz_lod( + self, + min_lod_pixels: typing.Optional[int], + max_lod_pixels: typing.Optional[int], + min_fade_extent: typing.Optional[int], + max_fade_extent: typing.Optional[int], + ) -> None: + lod = fastkml.views.Lod( + min_lod_pixels=min_lod_pixels, + max_lod_pixels=max_lod_pixels, + min_fade_extent=min_fade_extent, + max_fade_extent=max_fade_extent, + ) + + assert_repr_roundtrip(lod) + assert_str_roundtrip(lod) + assert_str_roundtrip_terse(lod) + assert_str_roundtrip_verbose(lod) + + @given( + north=st.one_of( + st.none(), + st.floats(allow_nan=False, allow_infinity=False, min_value=0, max_value=90), + ), + south=st.one_of( + st.none(), + st.floats(allow_nan=False, allow_infinity=False, min_value=0, max_value=90), + ), + east=st.one_of( + st.none(), + st.floats( + allow_nan=False, + allow_infinity=False, + min_value=0, + max_value=180, + ), + ), + west=st.one_of( + st.none(), + st.floats( + allow_nan=False, + allow_infinity=False, + min_value=0, + max_value=180, + ), + ), + min_altitude=st.one_of( + st.none(), + st.floats(allow_nan=False, allow_infinity=False).filter(lambda x: x != 0), + ), + max_altitude=st.one_of( + st.none(), + st.floats(allow_nan=False, allow_infinity=False).filter(lambda x: x != 0), + ), + altitude_mode=st.one_of(st.none(), st.sampled_from(fastkml.enums.AltitudeMode)), + ) + def test_fuzz_lat_lon_alt_box( + self, + north: typing.Optional[float], + south: typing.Optional[float], + east: typing.Optional[float], + west: typing.Optional[float], + min_altitude: typing.Optional[float], + max_altitude: typing.Optional[float], + altitude_mode: typing.Optional[fastkml.enums.AltitudeMode], + ) -> None: + lat_lon_alt_box = fastkml.views.LatLonAltBox( + north=north, + south=south, + east=east, + west=west, + min_altitude=min_altitude, + max_altitude=max_altitude, + altitude_mode=altitude_mode, + ) + + assert_repr_roundtrip(lat_lon_alt_box) + assert_str_roundtrip(lat_lon_alt_box) + assert_str_roundtrip_terse(lat_lon_alt_box) + assert_str_roundtrip_verbose(lat_lon_alt_box) + + @given( + id=st.one_of(st.none(), nc_name()), + target_id=st.one_of(st.none(), nc_name()), + lat_lon_alt_box=st.one_of(st.none(), lat_lon_alt_boxes()), + lod=st.one_of(st.none(), lods()), + ) + def test_fuzz_region( + self, + id: typing.Optional[str], + target_id: typing.Optional[str], + lat_lon_alt_box: typing.Optional[fastkml.views.LatLonAltBox], + lod: typing.Optional[fastkml.views.Lod], + ) -> None: + region = fastkml.views.Region( + id=id, + target_id=target_id, + lat_lon_alt_box=lat_lon_alt_box, + lod=lod, + ) + + assert_repr_roundtrip(region) + assert_str_roundtrip(region) + assert_str_roundtrip_terse(region) + assert_str_roundtrip_verbose(region) + + @common_view( + roll=st.one_of( + st.none(), + st.floats( + allow_nan=False, + allow_infinity=False, + min_value=-180, + max_value=180, + ).filter(lambda x: x != 0), + ), + ) + def test_fuzz_camera( + self, + id: typing.Optional[str], + target_id: typing.Optional[str], + longitude: typing.Optional[float], + latitude: typing.Optional[float], + altitude: typing.Optional[float], + heading: typing.Optional[float], + tilt: typing.Optional[float], + altitude_mode: typing.Optional[fastkml.enums.AltitudeMode], + roll: typing.Optional[float], + ) -> None: + camera = fastkml.Camera( + id=id, + target_id=target_id, + longitude=longitude, + latitude=latitude, + altitude=altitude, + heading=heading, + tilt=tilt, + roll=roll, + altitude_mode=altitude_mode, + ) + + assert_repr_roundtrip(camera) + assert_str_roundtrip(camera) + assert_str_roundtrip_terse(camera) + assert_str_roundtrip_verbose(camera) + + @common_view( + range=st.one_of( + st.none(), + st.floats(allow_nan=False, allow_infinity=False).filter(lambda x: x != 0), + ), + ) + def test_fuzz_look_at( + self, + id: typing.Optional[str], + target_id: typing.Optional[str], + longitude: typing.Optional[float], + latitude: typing.Optional[float], + altitude: typing.Optional[float], + heading: typing.Optional[float], + tilt: typing.Optional[float], + altitude_mode: typing.Optional[fastkml.enums.AltitudeMode], + range: typing.Optional[float], + ) -> None: + look_at = fastkml.LookAt( + id=id, + target_id=target_id, + longitude=longitude, + latitude=latitude, + altitude=altitude, + heading=heading, + tilt=tilt, + range=range, + altitude_mode=altitude_mode, + ) + + assert_repr_roundtrip(look_at) + assert_str_roundtrip(look_at) + assert_str_roundtrip_terse(look_at) + assert_str_roundtrip_verbose(look_at) diff --git a/tests/repr_eq_test.py b/tests/repr_eq_test.py index 65d827bf..2023f4d7 100644 --- a/tests/repr_eq_test.py +++ b/tests/repr_eq_test.py @@ -340,7 +340,6 @@ class TestRepr(StdLibrary): tilt=61.61116895973212, range=359.3753895394523, altitude_mode=AltitudeMode.relative_to_ground, - time_primitive=None, ), times=None, style_url=None, @@ -1485,7 +1484,6 @@ class TestRepr(StdLibrary): tilt=51.96, range=301.9568, altitude_mode=AltitudeMode.relative_to_ground, - time_primitive=None, ), times=None, style_url=None, diff --git a/tests/views_test.py b/tests/views_test.py index 097dfaae..68231f15 100644 --- a/tests/views_test.py +++ b/tests/views_test.py @@ -16,11 +16,7 @@ """Test the (Abstract)Views classes.""" -import datetime -from dateutil.tz import tzutc - -from fastkml import times from fastkml import views from fastkml.enums import AltitudeMode from tests.base import Lxml @@ -32,12 +28,6 @@ class TestStdLibrary(StdLibrary): def test_create_camera(self) -> None: """Test the creation of a camera.""" - time_span = times.TimeSpan( - id="time-span-id", - begin=times.KmlDateTime(datetime.datetime(2019, 1, 1, tzinfo=tzutc())), - end=times.KmlDateTime(datetime.datetime(2019, 1, 2, tzinfo=tzutc())), - ) - camera = views.Camera( id="cam-id", target_id="target-cam-id", @@ -48,7 +38,6 @@ def test_create_camera(self) -> None: altitude_mode=AltitudeMode("relativeToGround"), latitude=50, longitude=60, - time_primitive=time_span, ) assert camera.heading == 10 @@ -60,12 +49,6 @@ 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 == times.KmlDateTime( - datetime.datetime(2019, 1, 1, tzinfo=tzutc()), - ) - assert camera.end == times.KmlDateTime( - datetime.datetime(2019, 1, 2, tzinfo=tzutc()), - ) assert camera.to_string() def test_camera_read(self) -> None: @@ -73,10 +56,6 @@ def test_camera_read(self) -> None: camera_xml = ( '' - '' - "2019-01-01T00:00:00" - "2019-01-02T00:00:00" - "" "60" "50" "40" @@ -98,18 +77,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 == times.KmlDateTime( - datetime.datetime(2019, 1, 1, tzinfo=tzutc()), - ) - assert camera.end == times.KmlDateTime( - datetime.datetime(2019, 1, 2, tzinfo=tzutc()), - ) def test_create_look_at(self) -> None: - time_stamp = times.TimeStamp( - id="time-span-id", - timestamp=times.KmlDateTime(datetime.datetime(2019, 1, 1, tzinfo=tzutc())), - ) look_at = views.LookAt( id="look-at-id", @@ -120,7 +89,6 @@ def test_create_look_at(self) -> None: altitude_mode=AltitudeMode("relativeToGround"), latitude=50, longitude=60, - time_primitive=time_stamp, ) assert look_at.heading == 10 @@ -131,14 +99,6 @@ 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.times.timestamp.dt == datetime.datetime( - 2019, - 1, - 1, - tzinfo=tzutc(), - ) - assert look_at.begin is None - assert look_at.end is None assert look_at.to_string() def test_look_at_read(self) -> None: @@ -150,9 +110,6 @@ def test_look_at_read(self) -> None: "10" "20" "relativeToGround" - '' - "2019-01-01T00:00:00" - "" "30" "" ) @@ -166,14 +123,6 @@ 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.times.timestamp.dt == datetime.datetime( - 2019, - 1, - 1, - tzinfo=tzutc(), - ) - assert look_at.begin is None - assert look_at.end is None def test_region_with_all_optional_parameters(self) -> None: """Region object can be initialized with all optional parameters.""" From 36f05737e2c1233d775fb33adb741aaea83584fd Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Thu, 7 Nov 2024 23:03:33 +0000 Subject: [PATCH 10/18] fix doctest --- docs/working_with_kml.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/working_with_kml.rst b/docs/working_with_kml.rst index 0150953c..8cda819a 100644 --- a/docs/working_with_kml.rst +++ b/docs/working_with_kml.rst @@ -230,8 +230,8 @@ CascadingStyle element into a supported Style element. 13.96486261382906 0.0 0.0 - absolute 632.584179697442 + absolute #__managed_style_0D301BCC0014827EFCCB From c047b7ce19c929674ff3e6d1361cc0b40e65c28c Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Fri, 8 Nov 2024 14:07:19 +0000 Subject: [PATCH 11/18] add hypothesis tests for data and refactor --- fastkml/atom.py | 12 +-- fastkml/data.py | 110 ++++++++++----------- fastkml/helpers.py | 3 +- tests/atom_test.py | 4 +- tests/data_test.py | 21 +++- tests/hypothesis/common.py | 3 + tests/hypothesis/data_test.py | 169 +++++++++++++++++++++++++++++++++ tests/hypothesis/strategies.py | 8 ++ 8 files changed, 262 insertions(+), 68 deletions(-) create mode 100644 tests/hypothesis/data_test.py diff --git a/fastkml/atom.py b/fastkml/atom.py index fde10d0b..55548144 100644 --- a/fastkml/atom.py +++ b/fastkml/atom.py @@ -258,9 +258,9 @@ class _Person(_AtomObject): """ - name: str - uri: str - email: str + name: Optional[str] + uri: Optional[str] + email: Optional[str] def __init__( self, @@ -285,9 +285,9 @@ def __init__( """ super().__init__(ns=ns, name_spaces=name_spaces, **kwargs) - self.name = name.strip() if name else "" - self.uri = uri.strip() if uri else "" - self.email = email.strip() if email else "" + self.name = name.strip() or None if name else None + self.uri = uri.strip() or None if uri else None + self.email = email.strip() or None if email else None def __repr__(self) -> str: """ diff --git a/fastkml/data.py b/fastkml/data.py index 9d02a80b..2e695e33 100644 --- a/fastkml/data.py +++ b/fastkml/data.py @@ -18,6 +18,7 @@ https://developers.google.com/kml/documentation/extendeddata """ + import logging from typing import Any from typing import Dict @@ -26,6 +27,7 @@ from typing import Optional from typing import Union +from fastkml.base import _XMLObject from fastkml.enums import DataType from fastkml.exceptions import KMLSchemaError from fastkml.helpers import attribute_enum_kwarg @@ -53,7 +55,7 @@ logger = logging.getLogger(__name__) -class SimpleField(_BaseObject): +class SimpleField(_XMLObject): """ A SimpleField always has both name and type attributes. @@ -76,18 +78,18 @@ class SimpleField(_BaseObject): HTML markup. """ + _default_nsid = "kml" + name: Optional[str] - type: Optional[DataType] + type_: Optional[DataType] display_name: Optional[str] def __init__( self, ns: Optional[str] = None, name_spaces: Optional[Dict[str, str]] = None, - id: Optional[str] = None, - target_id: Optional[str] = None, name: Optional[str] = None, - type: Optional[DataType] = None, + type_: Optional[DataType] = None, display_name: Optional[str] = None, **kwargs: Any, ) -> None: @@ -99,10 +101,8 @@ def __init__( ns (Optional[str]): The namespace of the data. name_spaces (Optional[Dict[str, str]]): The dictionary of namespace prefixes and URIs. - id (Optional[str]): The ID of the data. - target_id (Optional[str]): The target ID of the data. name (Optional[str]): The name of the data. - type (Optional[DataType]): The type of the data. + type_ (Optional[DataType]): The type of the data. display_name (Optional[str]): The display name of the data. **kwargs (Any): Additional keyword arguments. @@ -114,13 +114,11 @@ def __init__( super().__init__( ns=ns, name_spaces=name_spaces, - id=id, - target_id=target_id, **kwargs, ) - self.name = name - self.type = type - self.display_name = display_name + self.name = name.strip() or None if name else None + self.type_ = type_ or None + self.display_name = display_name.strip() or None if display_name else None def __repr__(self) -> str: """ @@ -135,10 +133,8 @@ def __repr__(self) -> str: f"{self.__class__.__module__}.{self.__class__.__name__}(" f"ns={self.ns!r}, " f"name_spaces={self.name_spaces!r}, " - f"id={self.id!r}, " - f"target_id={self.target_id!r}, " f"name={self.name!r}, " - f"type={self.type}, " + f"type_={self.type_}, " f"display_name={self.display_name!r}, " f"**{self._get_splat()!r}," ")" @@ -153,13 +149,13 @@ def __bool__(self) -> bool: bool: True if both the name and type are non-empty, False otherwise. """ - return bool(self.name) and bool(self.type) + return bool(self.name) and bool(self.type_) registry.register( SimpleField, RegistryItem( - ns_ids=("kml",), + ns_ids=("kml", ""), attr_name="display_name", node_name="displayName", classes=(str,), @@ -182,7 +178,7 @@ def __bool__(self) -> bool: SimpleField, RegistryItem( ns_ids=("", "kml"), - attr_name="type", + attr_name="type_", node_name="type", classes=(DataType,), get_kwarg=attribute_enum_kwarg, @@ -191,7 +187,7 @@ def __bool__(self) -> bool: ) -class Schema(_BaseObject): +class Schema(_XMLObject): """ Specifies a custom KML schema that is used to add custom data to KML Features. @@ -202,6 +198,8 @@ class Schema(_BaseObject): """ + _default_nsid = "kml" + name: Optional[str] fields: List[SimpleField] @@ -210,7 +208,6 @@ def __init__( ns: Optional[str] = None, name_spaces: Optional[Dict[str, str]] = None, id: Optional[str] = None, - target_id: Optional[str] = None, name: Optional[str] = None, fields: Optional[Iterable[SimpleField]] = None, **kwargs: Any, @@ -241,18 +238,17 @@ def __init__( If the id is not provided. """ - if id is None: + if not id: msg = "Id is required for schema" raise KMLSchemaError(msg) super().__init__( ns=ns, name_spaces=name_spaces, - id=id, - target_id=target_id, **kwargs, ) - self.name = name + self.name = name.strip() or None if name else None self.fields = list(fields) if fields else [] + self.id = id.strip() or None if id else None def __repr__(self) -> str: """ @@ -269,7 +265,6 @@ def __repr__(self) -> str: f"ns={self.ns!r}, " f"name_spaces={self.name_spaces!r}, " f"id={self.id!r}, " - f"target_id={self.target_id!r}, " f"name={self.name!r}, " f"fields={self.fields!r}, " f"**{self._get_splat()!r}," @@ -289,6 +284,17 @@ def append(self, field: SimpleField) -> None: self.fields.append(field) +registry.register( + Schema, + RegistryItem( + ns_ids=("kml", ""), + attr_name="id", + node_name="id", + classes=(str,), + get_kwarg=attribute_text_kwarg, + set_element=text_attribute, + ), +) registry.register( Schema, RegistryItem( @@ -358,9 +364,9 @@ def __init__( target_id=target_id, **kwargs, ) - self.name = name - self.value = value - self.display_name = display_name + self.name = name.strip() or None if name else None + self.value = value.strip() or None if value else None + self.display_name = display_name.strip() or None if display_name else None def __repr__(self) -> str: """ @@ -395,26 +401,26 @@ def __bool__(self) -> bool: True if the Data object has a name and a non-None value, False otherwise. """ - return bool(self.name) and self.value is not None + return bool(self.name) and bool(self.value) registry.register( Data, RegistryItem( - ns_ids=("", "kml"), - attr_name="name", - node_name="name", + ns_ids=("kml", ""), + attr_name="display_name", + node_name="displayName", classes=(str,), - get_kwarg=attribute_text_kwarg, - set_element=text_attribute, + get_kwarg=subelement_text_kwarg, + set_element=text_subelement, ), ) registry.register( Data, RegistryItem( - ns_ids=("kml",), - attr_name="display_name", - node_name="displayName", + ns_ids=("kml", ""), + attr_name="value", + node_name="value", classes=(str,), get_kwarg=subelement_text_kwarg, set_element=text_subelement, @@ -424,16 +430,18 @@ def __bool__(self) -> bool: Data, RegistryItem( ns_ids=("", "kml"), - attr_name="value", - node_name="value", + attr_name="name", + node_name="name", classes=(str,), - get_kwarg=subelement_text_kwarg, - set_element=text_subelement, + get_kwarg=attribute_text_kwarg, + set_element=text_attribute, ), ) -class SimpleData(_BaseObject): +class SimpleData(_XMLObject): + _default_nsid = "kml" + name: Optional[str] value: Optional[str] @@ -441,8 +449,6 @@ def __init__( self, ns: Optional[str] = None, name_spaces: Optional[Dict[str, str]] = None, - id: Optional[str] = None, - target_id: Optional[str] = None, name: Optional[str] = None, value: Optional[str] = None, **kwargs: Any, @@ -455,8 +461,6 @@ def __init__( ns (Optional[str]): The namespace of the object. name_spaces (Optional[Dict[str, str]]): The dictionary of namespace prefixes and URIs. - id (Optional[str]): The ID of the object. - target_id (Optional[str]): The target ID of the object. name (Optional[str]): The name of the object. value (Optional[str]): The value of the object. **kwargs: Additional keyword arguments. @@ -465,12 +469,10 @@ def __init__( super().__init__( ns=ns, name_spaces=name_spaces, - id=id, - target_id=target_id, **kwargs, ) - self.name = name - self.value = value + self.name = name.strip() or None if name else None + self.value = value.strip() or None if value else None def __repr__(self) -> str: """ @@ -485,8 +487,6 @@ def __repr__(self) -> str: f"{self.__class__.__module__}.{self.__class__.__name__}(" f"ns={self.ns!r}, " f"name_spaces={self.name_spaces!r}, " - f"id={self.id!r}, " - f"target_id={self.target_id!r}, " f"name={self.name!r}, " f"value={self.value!r}, " f"**{self._get_splat()!r}," @@ -502,7 +502,7 @@ def __bool__(self) -> bool: bool: True if the name and the value attribute are set, False otherwise. """ - return bool(self.name) and self.value is not None + return bool(self.name) and bool(self.value) registry.register( @@ -579,7 +579,7 @@ def __init__( target_id=target_id, **kwargs, ) - self.schema_url = schema_url + self.schema_url = schema_url.strip() or None if schema_url else None self.data = list(data) if data else [] def __repr__(self) -> str: diff --git a/fastkml/helpers.py b/fastkml/helpers.py index 51d8791e..c2104a6d 100644 --- a/fastkml/helpers.py +++ b/fastkml/helpers.py @@ -645,8 +645,9 @@ def subelement_text_kwarg( """ node = element.find(f"{ns}{node_name}") - if node is None: + if node is None or node.text is None: return {} + assert isinstance(node.text, str) # noqa: S101 return {kwarg: node.text.strip()} if node.text and node.text.strip() else {} diff --git a/tests/atom_test.py b/tests/atom_test.py index 56635fc5..c243a4ae 100644 --- a/tests/atom_test.py +++ b/tests/atom_test.py @@ -145,14 +145,14 @@ def test_atom_contributor_read_no_name(self) -> None: ns="{http://www.w3.org/2005/Atom}", ) - assert a.name == "" + assert a.name is None assert a.uri == "http://localhost" assert a.email == "cl@donotreply.com" def test_atom_contributor_no_name(self) -> None: a = atom.Contributor(uri="http://localhost", email="cl@donotreply.com") - assert a.name == "" + assert a.name is None assert "atom:name" not in a.to_string() def test_atom_contributor_roundtrip(self) -> None: diff --git a/tests/data_test.py b/tests/data_test.py index b142aa95..2372d9db 100644 --- a/tests/data_test.py +++ b/tests/data_test.py @@ -14,6 +14,7 @@ # along with this library; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Test the gx classes.""" + import pytest import fastkml as kml @@ -30,6 +31,18 @@ class TestStdLibrary(StdLibrary): def test_schema_requires_id(self) -> None: pytest.raises(KMLSchemaError, kml.Schema, "") + def test_simple_field_from_string_0(self) -> None: + doc = ( + '' + "0" + ) + + sf = data.SimpleField.from_string(doc) + + assert sf.name == "0" + assert sf.type_ == DataType("string") + assert sf.display_name == "0" + def test_schema(self) -> None: ns = "{http://www.opengis.net/kml/2.2}" s = kml.Schema(ns=ns, id="some_id") @@ -68,9 +81,9 @@ def test_schema_from_string(self) -> None: s = kml.Schema.from_string(doc, ns=None) assert len(s.fields) == 3 - assert s.fields[0].type == DataType("string") - assert s.fields[1].type == DataType("double") - assert s.fields[2].type == DataType("int") + assert s.fields[0].type_ == DataType("string") + assert s.fields[1].type_ == DataType("double") + assert s.fields[2].type_ == DataType("int") assert s.fields[0].name == "TrailHeadName" assert s.fields[1].name == "TrailLength" assert s.fields[2].name == "ElevationGain" @@ -79,7 +92,7 @@ def test_schema_from_string(self) -> None: assert s.fields[2].display_name == "change in altitude" s1 = kml.Schema.from_string(s.to_string(), ns=None) assert len(s1.fields) == 3 - assert s1.fields[0].type == DataType("string") + assert s1.fields[0].type_ == DataType("string") assert s1.fields[1].name == "TrailLength" assert s1.fields[2].display_name == "change in altitude" assert s.to_string() == s1.to_string() diff --git a/tests/hypothesis/common.py b/tests/hypothesis/common.py index 53dee19e..843a78cc 100644 --- a/tests/hypothesis/common.py +++ b/tests/hypothesis/common.py @@ -14,6 +14,7 @@ # along with this library; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Common functionality for property based tests.""" + import datetime import logging @@ -31,6 +32,7 @@ import fastkml from fastkml.base import _XMLObject from fastkml.enums import AltitudeMode +from fastkml.enums import DataType from fastkml.enums import DateTimeResolution from fastkml.enums import RefreshMode from fastkml.enums import Verbosity @@ -58,6 +60,7 @@ "Angle": Angle, "datetime": datetime, "DateTimeResolution": DateTimeResolution, + "DataType": DataType, "tzutc": tzutc, "tzfile": tzfile, } diff --git a/tests/hypothesis/data_test.py b/tests/hypothesis/data_test.py new file mode 100644 index 00000000..bae642b5 --- /dev/null +++ b/tests/hypothesis/data_test.py @@ -0,0 +1,169 @@ +# Copyright (C) 2024 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 +"""Property-based tests for the views module.""" + +import typing +from functools import partial + +from hypothesis import given +from hypothesis import strategies as st +from hypothesis.provisional import urls + +import fastkml +import fastkml.data +import fastkml.enums +from tests.base import Lxml +from tests.hypothesis.common import assert_repr_roundtrip +from tests.hypothesis.common import assert_str_roundtrip +from tests.hypothesis.common import assert_str_roundtrip_terse +from tests.hypothesis.common import assert_str_roundtrip_verbose +from tests.hypothesis.strategies import nc_name +from tests.hypothesis.strategies import xml_text + +simple_fields = partial( + st.builds, + fastkml.data.SimpleField, + name=xml_text().filter(lambda x: x.strip() != ""), + type_=st.one_of(st.sampled_from(fastkml.enums.DataType)), + display_name=xml_text().filter(lambda x: x.strip() != ""), +) + + +class TestLxml(Lxml): + @given( + name=st.one_of(st.none(), xml_text()), + type_=st.one_of(st.sampled_from(fastkml.enums.DataType)), + display_name=st.one_of(st.none(), xml_text()), + ) + def test_fuzz_simple_field( + self, + name: typing.Optional[str], + type_: typing.Optional[fastkml.enums.DataType], + display_name: typing.Optional[str], + ) -> None: + simple_field = fastkml.data.SimpleField( + name=name, + type_=type_, + display_name=display_name, + ) + + assert_str_roundtrip(simple_field) + assert_repr_roundtrip(simple_field) + assert_str_roundtrip_terse(simple_field) + assert_str_roundtrip_verbose(simple_field) + + @given( + id=nc_name(), + name=st.one_of(st.none(), xml_text()), + fields=st.one_of(st.none(), st.lists(simple_fields())), + ) + def test_fuzz_schema( + self, + id: typing.Optional[str], + name: typing.Optional[str], + fields: typing.Optional[typing.Iterable[fastkml.data.SimpleField]], + ) -> None: + schema = fastkml.Schema( + id=id, + name=name, + fields=fields, + ) + + assert_str_roundtrip(schema) + assert_repr_roundtrip(schema) + assert_str_roundtrip_terse(schema) + assert_str_roundtrip_verbose(schema) + + @given( + id=st.one_of(st.none(), nc_name()), + target_id=st.one_of(st.none(), nc_name()), + name=st.one_of(st.none(), xml_text()), + value=xml_text().filter(lambda x: x.strip() != ""), + display_name=st.one_of(st.none(), xml_text()), + ) + def test_fuzz_data( + self, + id: typing.Optional[str], + target_id: typing.Optional[str], + name: typing.Optional[str], + value: typing.Optional[str], + display_name: typing.Optional[str], + ) -> None: + data = fastkml.Data( + id=id, + target_id=target_id, + name=name, + value=value, + display_name=display_name, + ) + + assert_str_roundtrip(data) + assert_repr_roundtrip(data) + assert_str_roundtrip_terse(data) + assert_str_roundtrip_verbose(data) + + @given( + name=xml_text().filter(lambda x: x.strip() != ""), + value=xml_text().filter(lambda x: x.strip() != ""), + ) + def test_fuzz_simple_data( + self, + name: typing.Optional[str], + value: typing.Optional[str], + ) -> None: + simple_data = fastkml.data.SimpleData( + name=name, + value=value, + ) + + assert_str_roundtrip(simple_data) + assert_repr_roundtrip(simple_data) + assert_str_roundtrip_terse(simple_data) + assert_str_roundtrip_verbose(simple_data) + + @given( + id=st.one_of(st.none(), nc_name()), + target_id=st.one_of(st.none(), nc_name()), + schema_url=st.one_of(st.none(), urls()), + data=st.one_of( + st.none(), + st.lists( + st.builds( + fastkml.data.SimpleData, + name=xml_text().filter(lambda x: x.strip() != ""), + value=xml_text().filter(lambda x: x.strip() != ""), + ), + ), + ), + ) + def test_fuzz_schema_data( + self, + id: typing.Optional[str], + target_id: typing.Optional[str], + schema_url: typing.Optional[str], + data: typing.Optional[typing.Iterable[fastkml.data.SimpleData]], + ) -> None: + schema_data = fastkml.SchemaData( + id=id, + target_id=target_id, + schema_url=schema_url, + data=data, + ) + + assert_str_roundtrip(schema_data) + assert_repr_roundtrip(schema_data) + assert_str_roundtrip_terse(schema_data) + assert_str_roundtrip_verbose(schema_data) diff --git a/tests/hypothesis/strategies.py b/tests/hypothesis/strategies.py index 8f6cd19c..83f8f3ae 100644 --- a/tests/hypothesis/strategies.py +++ b/tests/hypothesis/strategies.py @@ -15,6 +15,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Custom hypothesis strategies for testing.""" + import datetime import re import string @@ -52,6 +53,13 @@ st.text, alphabet=st.characters(min_codepoint=1, blacklist_categories=("Cc", "Cs")), ) +uri_text = partial( + st.from_regex, + regex=re.compile(r"^[a-zA-Z][a-zA-Z0-9+\-.]*://.+$"), + alphabet=f"{string.ascii_letters}{string.digits}+-.:/", + fullmatch=True, +) + kml_datetimes = partial( st.builds, From 0be5b068a7bbfd524319f908056adddeb9d9975e Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Fri, 8 Nov 2024 15:36:00 +0000 Subject: [PATCH 12/18] add hypothesis test for ExtendedData, refactor ExtendedData --- fastkml/data.py | 15 ++++-------- tests/hypothesis/data_test.py | 45 +++++++++++++++++++++++++++++++++++ tests/repr_eq_test.py | 3 +-- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/fastkml/data.py b/fastkml/data.py index 2e695e33..49c1169f 100644 --- a/fastkml/data.py +++ b/fastkml/data.py @@ -508,7 +508,7 @@ def __bool__(self) -> bool: registry.register( SimpleData, RegistryItem( - ns_ids=("kml",), + ns_ids=("kml", ""), attr_name="value", node_name="value", classes=(str,), @@ -644,17 +644,16 @@ def append_data(self, data: SimpleData) -> None: ) -class ExtendedData(_BaseObject): +class ExtendedData(_XMLObject): """Represents a list of untyped name/value pairs.""" + _default_nsid = "kml" elements: List[Union[Data, SchemaData]] def __init__( self, ns: Optional[str] = None, name_spaces: Optional[Dict[str, str]] = None, - id: Optional[str] = None, - target_id: Optional[str] = None, elements: Optional[Iterable[Union[Data, SchemaData]]] = None, **kwargs: Any, ) -> None: @@ -666,8 +665,6 @@ def __init__( ns (Optional[str]): The namespace for the data. name_spaces (Optional[Dict[str, str]]): The dictionary of namespace prefixes and URIs. - id (Optional[str]): The ID of the data. - target_id (Optional[str]): The target ID of the data. elements (Optional[Iterable[Union[Data, SchemaData]]]): The iterable of data elements. **kwargs (Any): Additional keyword arguments. @@ -680,11 +677,9 @@ def __init__( super().__init__( ns=ns, name_spaces=name_spaces, - id=id, - target_id=target_id, **kwargs, ) - self.elements = list(elements) if elements else [] + self.elements = [e for e in elements if e] if elements else [] def __repr__(self) -> str: """ @@ -699,8 +694,6 @@ def __repr__(self) -> str: f"{self.__class__.__module__}.{self.__class__.__name__}(" f"ns={self.ns!r}, " f"name_spaces={self.name_spaces!r}, " - f"id={self.id!r}, " - f"target_id={self.target_id!r}, " f"elements={self.elements!r}, " f"**{self._get_splat()!r}," ")" diff --git a/tests/hypothesis/data_test.py b/tests/hypothesis/data_test.py index bae642b5..47d61f9c 100644 --- a/tests/hypothesis/data_test.py +++ b/tests/hypothesis/data_test.py @@ -167,3 +167,48 @@ def test_fuzz_schema_data( assert_repr_roundtrip(schema_data) assert_str_roundtrip_terse(schema_data) assert_str_roundtrip_verbose(schema_data) + + @given( + elements=st.one_of( + st.none(), + st.lists( + st.one_of( + st.builds( + fastkml.data.Data, + name=xml_text().filter(lambda x: x.strip() != ""), + value=xml_text().filter(lambda x: x.strip() != ""), + display_name=st.one_of(st.none(), xml_text()), + ), + st.builds( + fastkml.SchemaData, + id=st.one_of(st.none(), nc_name()), + target_id=st.one_of(st.none(), nc_name()), + schema_url=st.one_of(st.none(), urls()), + data=st.lists( + st.builds( + fastkml.data.SimpleData, + name=xml_text().filter(lambda x: x.strip() != ""), + value=xml_text().filter(lambda x: x.strip() != ""), + ), + ), + ), + ), + ), + ), + ) + def test_fuzz_extended_data( + self, + elements: typing.Optional[ + typing.Iterable[typing.Union[fastkml.Data, fastkml.SchemaData]] + ], + ) -> None: + extended_data = fastkml.ExtendedData( + elements=sorted(elements, key=lambda t: t.__class__.__name__) + if elements + else None, + ) + + assert_repr_roundtrip(extended_data) + assert_str_roundtrip(extended_data) + assert_str_roundtrip_terse(extended_data) + assert_str_roundtrip_verbose(extended_data) diff --git a/tests/repr_eq_test.py b/tests/repr_eq_test.py index 2023f4d7..2ec4baf0 100644 --- a/tests/repr_eq_test.py +++ b/tests/repr_eq_test.py @@ -15,6 +15,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Test the __repr__ and __eq__ methods.""" + import difflib from textwrap import wrap from typing import Final @@ -1621,8 +1622,6 @@ class TestRepr(StdLibrary): "atom": "{http://www.w3.org/2005/Atom}", "gx": "{http://www.google.com/kml/ext/2.2}", }, - id="ed-001", - target_id="", elements=[ fastkml.data.Data( ns="{http://www.opengis.net/kml/2.2}", From 5e22dc1afca518b4ca0deed80f0ec06a3c0da5f5 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Fri, 8 Nov 2024 16:00:05 +0000 Subject: [PATCH 13/18] schemadata does not need to have an ID in hypothesis tests --- tests/hypothesis/data_test.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/hypothesis/data_test.py b/tests/hypothesis/data_test.py index 47d61f9c..37882f8c 100644 --- a/tests/hypothesis/data_test.py +++ b/tests/hypothesis/data_test.py @@ -181,8 +181,6 @@ def test_fuzz_schema_data( ), st.builds( fastkml.SchemaData, - id=st.one_of(st.none(), nc_name()), - target_id=st.one_of(st.none(), nc_name()), schema_url=st.one_of(st.none(), urls()), data=st.lists( st.builds( From b9d37e023b4ac09ed057fce428c11fc15ee30365 Mon Sep 17 00:00:00 2001 From: Christian Ledermann Date: Sat, 9 Nov 2024 13:23:26 +0000 Subject: [PATCH 14/18] Add hypothesis test for styles and refactor StyleUrl class --- docs/working_with_kml.rst | 9 +- fastkml/styles.py | 20 +-- tests/hypothesis/common.py | 6 + tests/hypothesis/strategies.py | 11 ++ tests/hypothesis/style_test.py | 267 +++++++++++++++++++++++++++++++++ tests/repr_eq_test.py | 14 -- tests/styles_test.py | 15 +- 7 files changed, 302 insertions(+), 40 deletions(-) create mode 100644 tests/hypothesis/style_test.py diff --git a/docs/working_with_kml.rst b/docs/working_with_kml.rst index 8cda819a..ab630a61 100644 --- a/docs/working_with_kml.rst +++ b/docs/working_with_kml.rst @@ -164,6 +164,8 @@ Now we can create a new KML object and confirm that the new element is parsed co To be able to open the KML file in Google Earth Pro, we need to transform the CascadingStyle element into a supported Style element. +To achieve this we copy the styles into the document styles and adjust their id +to match the id of the CascadingStyle. .. code-block:: pycon @@ -173,6 +175,11 @@ CascadingStyle element into a supported Style element. ... kml_style.id = cascading_style.id ... document.styles.append(kml_style) ... + +Now we can remove the CascadingStyle from the document and have a look at the result. + +.. code-block:: pycon + >>> document.gx_cascading_style = [] >>> print(document.to_string(prettyprint=True)) @@ -222,7 +229,7 @@ CascadingStyle element into a supported Style element. 80000000 - + Ort1 10.06256752902339 diff --git a/fastkml/styles.py b/fastkml/styles.py index c70de2c7..ebd9a1ff 100644 --- a/fastkml/styles.py +++ b/fastkml/styles.py @@ -62,7 +62,7 @@ logger = logging.getLogger(__name__) -class StyleUrl(_BaseObject): +class StyleUrl(_XMLObject): """ URL of a