diff --git a/.github/workflows/run-all-tests.yml b/.github/workflows/run-all-tests.yml index 4e47acd1..e6ca054a 100644 --- a/.github/workflows/run-all-tests.yml +++ b/.github/workflows/run-all-tests.yml @@ -46,7 +46,7 @@ jobs: pytest tests --cov=fastkml --cov=tests --cov-fail-under=95 --cov-report=xml - name: "Upload coverage to Codecov" if: ${{ matrix.python-version==3.11 }} - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: fail_ci_if_error: true verbose: true diff --git a/fastkml/about.py b/fastkml/about.py index da54aaa9..578573fc 100644 --- a/fastkml/about.py +++ b/fastkml/about.py @@ -19,6 +19,6 @@ The only purpose of this module is to provide a version number for the package. """ -__version__ = "1.0.0rc1" +__version__ = "1.0.0rc2" __all__ = ["__version__"] diff --git a/fastkml/base.py b/fastkml/base.py index b876d5eb..88e7c0dd 100644 --- a/fastkml/base.py +++ b/fastkml/base.py @@ -189,10 +189,8 @@ def to_string( str, config.etree.tostring( element, - encoding="UTF-8", + encoding="unicode", pretty_print=prettyprint, - ).decode( - "UTF-8", ), ) except TypeError: @@ -200,9 +198,7 @@ def to_string( str, config.etree.tostring( element, - encoding="UTF-8", - ).decode( - "UTF-8", + encoding="unicode", ), ) diff --git a/fastkml/helpers.py b/fastkml/helpers.py index 91fdd273..85a888e8 100644 --- a/fastkml/helpers.py +++ b/fastkml/helpers.py @@ -1092,7 +1092,7 @@ def datetime_subelement_kwarg( kwarg: str, classes: Tuple[Type[object], ...], strict: bool, -) -> Dict[str, List["KmlDateTime"]]: +) -> Dict[str, "KmlDateTime"]: """Extract a KML datetime from a subelement of an XML element.""" cls = classes[0] node = element.find(f"{ns}{node_name}") @@ -1100,15 +1100,16 @@ def datetime_subelement_kwarg( return {} node_text = node.text.strip() if node.text else "" if node_text: - if kdt := cls.parse(node_text): # type: ignore[attr-defined] - return {kwarg: kdt} - handle_error( - error=ValueError(f"Invalid DateTime value: {node_text}"), - strict=strict, - element=element, - node=node, - expected="DateTime", - ) + try: + return {kwarg: cls.parse(node_text)} # type: ignore[attr-defined] + except ValueError as exc: + handle_error( + error=exc, + strict=strict, + element=element, + node=node, + expected="DateTime", + ) return {} diff --git a/fastkml/kml.py b/fastkml/kml.py index 57df7d04..a0314c11 100644 --- a/fastkml/kml.py +++ b/fastkml/kml.py @@ -27,6 +27,7 @@ """ import logging +import zipfile from pathlib import Path from typing import IO from typing import Any @@ -244,6 +245,48 @@ def parse( element=root, ) + def write( + self, + file_path: Path, + *, + prettyprint: bool = True, + precision: Optional[int] = None, + verbosity: Verbosity = Verbosity.normal, + ) -> None: + """ + Write KML to a file. + + Args: + ---- + file_path: The file name where to save the file. + Can be any string value + prettyprint : bool, default=True + Whether to pretty print the XML. + precision (Optional[int]): The precision used for floating-point values. + verbosity (Verbosity): The verbosity level for generating the KML element. + + """ + element = self.etree_element(precision=precision, verbosity=verbosity) + + try: + xml_content = config.etree.tostring( + element, + encoding="unicode", + pretty_print=prettyprint, + ) + except TypeError: + xml_content = config.etree.tostring( + element, + encoding="unicode", + ) + + if file_path.suffix == ".kmz": + with zipfile.ZipFile(file_path, "w", zipfile.ZIP_DEFLATED) as kmz: + kmz.writestr("doc.kml", xml_content) + else: + with file_path.open("w", encoding="UTF-8") as file: + file.write(xml_content) + registry.register( KML, diff --git a/fastkml/times.py b/fastkml/times.py index 093902df..a2f35b5f 100644 --- a/fastkml/times.py +++ b/fastkml/times.py @@ -45,7 +45,7 @@ # regular expression to parse a gYearMonth string # year and month may be separated by an optional dash -# year is always 4 digits, month is always 2 digits +# year is always 4 digits, month, day is always 2 digits year_month_day = re.compile( r"^(?P\d{4})(?:-)?(?P\d{2})?(?:-)?(?P\d{2})?$", ) @@ -205,10 +205,8 @@ def parse(cls, datestr: str) -> Optional["KmlDateTime"]: resolution = DateTimeResolution.year_month if year_month_day_match.group("month") is None: resolution = DateTimeResolution.year - elif len(datestr) > 10: # noqa: PLR2004 - dt = arrow.get(datestr).datetime - resolution = DateTimeResolution.datetime - return cls(dt, resolution) if dt else None + return cls(dt, resolution) + return cls(arrow.get(datestr).datetime, DateTimeResolution.datetime) @classmethod def get_ns_id(cls) -> str: diff --git a/fastkml/utils.py b/fastkml/utils.py index 87cc7d38..9f3bc0f7 100644 --- a/fastkml/utils.py +++ b/fastkml/utils.py @@ -30,6 +30,32 @@ def has_attribute_values(obj: object, **kwargs: Any) -> bool: return False +def get_all_attrs(obj: object) -> Generator[object, None, None]: + """ + Get all attributes of an object. + + Args: + ---- + obj: The object to get attributes from. + + Returns: + ------- + An iterable of all attributes of the object or, if the attribute itself is + iterable, iterate over the attribute values. + + """ + try: + attrs = (attr for attr in obj.__dict__ if not attr.startswith("_")) + except AttributeError: + return + for attr_name in attrs: + attr = getattr(obj, attr_name) + try: + yield from attr + except TypeError: + yield attr + + def find_all( obj: object, *, @@ -55,17 +81,9 @@ def find_all( **kwargs, ): yield obj - try: - attrs = (attr for attr in obj.__dict__ if not attr.startswith("_")) - except AttributeError: - return - for attr_name in attrs: - attr = getattr(obj, attr_name) - try: - for item in attr: - yield from find_all(item, of_type=of_type, **kwargs) - except TypeError: - yield from find_all(attr, of_type=of_type, **kwargs) + + for attr in get_all_attrs(obj): + yield from find_all(attr, of_type=of_type, **kwargs) def find( diff --git a/tests/atom_test.py b/tests/atom_test.py index 3c0df4bf..4e4f97f7 100644 --- a/tests/atom_test.py +++ b/tests/atom_test.py @@ -25,11 +25,9 @@ class TestStdLibrary(StdLibrary): """Test with the standard library.""" def test_atom_link_ns(self) -> None: - ns = "{http://www.opengis.net/kml/2.2}" - link = atom.Link(ns=ns) - assert link.ns == ns + link = atom.Link() assert link.to_string().startswith( - ' None: diff --git a/tests/kml_test.py b/tests/kml_test.py index c6be2344..2e2c4164 100644 --- a/tests/kml_test.py +++ b/tests/kml_test.py @@ -18,6 +18,8 @@ import io import pathlib +import tempfile +import zipfile import pygeoif as geo import pytest @@ -186,63 +188,71 @@ def test_kml_parse(self) -> None: assert doc.ns == "None" -class TestLxml(Lxml, TestStdLibrary): - """Test with lxml.""" - - -class TestLxmlParseKML(Lxml, TestParseKML): - """Test with Lxml.""" - - def test_from_string_with_unbound_prefix_strict(self) -> None: - doc = io.StringIO( - '' - "" - "image.png" - "" - " ", +class TestWriteKML(StdLibrary): + def test_write_kml_file(self) -> None: + doc = kml.KML( + ns="{http://www.opengis.net/kml/2.2}", + name="Vestibulum eleifend lobortis lorem.", + features=[ + Document( + ns="{http://www.opengis.net/kml/2.2}", + id="doc-001", + target_id="", + name="Vestibulum eleifend lobortis lorem.", + features=[ + Placemark( + ns="{http://www.opengis.net/kml/2.2}", + ), + ], + schemata=[], + ), + ], ) + with tempfile.TemporaryDirectory() as tmpdir_name: + file_path = pathlib.Path(tmpdir_name) / "output.kml" - with pytest.raises( - AssertionError, - match="^Element 'lc:attachment': This element is not expected.", - ): - kml.KML.parse(doc, ns="{http://www.opengis.net/kml/2.2}") - - def test_from_string_with_unbound_prefix_relaxed(self) -> None: - doc = io.StringIO( - '' - "" - "image.png" - "" - " ", - ) - k = kml.KML.parse(doc, strict=False) - assert len(k.features) == 1 - assert isinstance(k.features[0], features.Placemark) + doc.write(file_path=file_path, prettyprint=True) - def test_from_string_with_unbound_prefix_strict_no_validate(self) -> None: - doc = io.StringIO( - '' - "" - "image.png" - "" - " ", - ) - k = kml.KML.parse(doc, ns="{http://www.opengis.net/kml/2.2}", validate=False) - assert len(k.features) == 1 - assert isinstance(k.features[0], features.Placemark) + assert file_path.is_file(), "KML file was not created." + parsed_doc = kml.KML.parse(file_path) + assert parsed_doc.to_string() == doc.to_string() - def test_from_string_no_namespace(self) -> None: - doc = io.StringIO( - "", + def test_write_kmz_file(self) -> None: + doc = kml.KML( + ns="{http://www.opengis.net/kml/2.2}", + name="Vestibulum eleifend lobortis lorem.", + features=[ + Document( + ns="{http://www.opengis.net/kml/2.2}", + id="doc-001", + target_id="", + name="Vestibulum eleifend lobortis lorem.", + features=[ + Placemark( + ns="{http://www.opengis.net/kml/2.2}", + ), + ], + schemata=[], + ), + ], ) + with tempfile.TemporaryDirectory() as tmpdir_name: + file_path = pathlib.Path(tmpdir_name) / "output.kmz" - k = kml.KML.parse(doc, ns="", strict=False) + doc.write(file_path=file_path, prettyprint=True) - assert len(k.features) == 0 + assert file_path.is_file(), "KMZ file was not created." + tree = doc.to_string() + with zipfile.ZipFile(file_path, "r") as kmz: + assert "doc.kml" in kmz.namelist(), "doc.kml not found in the KMZ file" + with kmz.open("doc.kml") as doc_kml: + kml_content = doc_kml.read().decode("utf-8") + assert ( + kml_content == tree + ), "KML content does not match expected content" -class TestKmlFromString: +class TestKmlFromString(StdLibrary): def test_document(self) -> None: doc = """ @@ -597,3 +607,67 @@ def test_groundoverlay(self) -> None: doc2 = kml.KML.from_string(doc.to_string()) assert doc.to_string() == doc2.to_string() + + +class TestLxml(Lxml, TestStdLibrary): + """Test with lxml.""" + + +class TestLxmlParseKML(Lxml, TestParseKML): + """Test with Lxml.""" + + def test_from_string_with_unbound_prefix_strict(self) -> None: + doc = io.StringIO( + '' + "" + "image.png" + "" + " ", + ) + + with pytest.raises( + AssertionError, + match="^Element 'lc:attachment': This element is not expected.", + ): + kml.KML.parse(doc, ns="{http://www.opengis.net/kml/2.2}") + + def test_from_string_with_unbound_prefix_relaxed(self) -> None: + doc = io.StringIO( + '' + "" + "image.png" + "" + " ", + ) + k = kml.KML.parse(doc, strict=False) + assert len(k.features) == 1 + assert isinstance(k.features[0], features.Placemark) + + def test_from_string_with_unbound_prefix_strict_no_validate(self) -> None: + doc = io.StringIO( + '' + "" + "image.png" + "" + " ", + ) + k = kml.KML.parse(doc, ns="{http://www.opengis.net/kml/2.2}", validate=False) + assert len(k.features) == 1 + assert isinstance(k.features[0], features.Placemark) + + def test_from_string_no_namespace(self) -> None: + doc = io.StringIO( + "", + ) + + k = kml.KML.parse(doc, ns="", strict=False) + + assert len(k.features) == 0 + + +class TestWriteKMLLxmk(Lxml, TestWriteKML): + """Test with lxml.""" + + +class TestKmlFromStringLxml(Lxml, TestKmlFromString): + """Test with lxml.""" diff --git a/tests/times_test.py b/tests/times_test.py index 3b52e9ff..2b96978a 100644 --- a/tests/times_test.py +++ b/tests/times_test.py @@ -200,11 +200,19 @@ def test_parse_datetime_no_tz(self) -> None: assert dt.dt == datetime.datetime(1997, 7, 16, 7, 30, 15, tzinfo=tzutc()) def test_parse_datetime_empty(self) -> None: - assert KmlDateTime.parse("") is None + with pytest.raises( + ValueError, + match="^Could not match input '' to any of the following formats:", + ): + KmlDateTime.parse("") def test_parse_year_month_5(self) -> None: """Test that a single digit month is invalid.""" - assert KmlDateTime.parse("19973") is None + with pytest.raises( + ValueError, + match="^Could not match input '19973' to any of the following formats:", + ): + KmlDateTime.parse("19973") class TestStdLibrary(StdLibrary): diff --git a/tox.ini b/tox.ini index 3456d188..4fb8dc1a 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,6 @@ per-file-ignores = examples/*.py: DALL fastkml/gx.py: LIT002 fastkml/views.py: LIT002 - fastkml/utils.py: CCR001 fastkml/registry.py: E704 docs/conf.py: E402 enable-extensions=G