diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 49d7423f..9947b93c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,6 +51,12 @@ repos: rev: 7.1.1 hooks: - id: flake8 + additional_dependencies: + - flake8-cognitive-complexity + - flake8-comments + - flake8-dunder-all + - flake8-encodings + - flake8-expression-complexity - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: diff --git a/docs/create_kml_files.rst b/docs/create_kml_files.rst index 0b6938a6..e81a5538 100644 --- a/docs/create_kml_files.rst +++ b/docs/create_kml_files.rst @@ -166,53 +166,54 @@ Each placemark will have a time-span that covers the whole year: .. 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) + >>> 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: diff --git a/docs/working_with_kml.rst b/docs/working_with_kml.rst index 172ad5bc..3da5942e 100644 --- a/docs/working_with_kml.rst +++ b/docs/working_with_kml.rst @@ -45,7 +45,7 @@ We could also search for all Points, which will also return the Points inside th POINT Z (-123.3215766 49.2760338 0.0) POINT Z (-123.2643704 49.3301853 0.0) POINT Z (-123.2477084 49.2890857 0.0) - + POINT Z (8.23 46.707 0.0) ``find_all`` can also search for arbitrary elements by their attributes, by passing the @@ -152,11 +152,14 @@ And register the new element with the KML Document object: The CascadingStyle object is now part of the KML document and can be accessed like any other element. -Now we can create a new KML object and confirm that the new element is parsed correctly: +When parsing the document we have to skip the validation as the ``gx:CascadingStyle`` is +not in the XSD Schema. + +Create a new KML object and confirm that the new element is parsed correctly: .. code-block:: pycon - >>> cs_kml = KML.parse("examples/gx_cascading_style.kml") + >>> cs_kml = KML.parse("examples/gx_cascading_style.kml", validate=False) >>> cs = find(cs_kml, of_type=CascadingStyle) >>> cs.style # doctest: +ELLIPSIS fastkml.styles.Style(... @@ -181,7 +184,7 @@ Now we can remove the CascadingStyle from the document and have a look at the re .. code-block:: pycon >>> document.gx_cascading_style = [] - >>> print(document.to_string(prettyprint=True)) + >>> print(document) Test2 diff --git a/examples/transform_cascading_style.py b/examples/transform_cascading_style.py index 42d7df89..0b1ef3c3 100755 --- a/examples/transform_cascading_style.py +++ b/examples/transform_cascading_style.py @@ -69,7 +69,7 @@ def __init__( ), ) -cs_kml = KML.parse(examples_dir / "gx_cascading_style.kml") +cs_kml = KML.parse(examples_dir / "gx_cascading_style.kml", validate=False) document = find(cs_kml, of_type=Document) for cascading_style in document.gx_cascading_style: kml_style = cascading_style.style diff --git a/fastkml/__init__.py b/fastkml/__init__.py index d0cb773f..b607e08f 100644 --- a/fastkml/__init__.py +++ b/fastkml/__init__.py @@ -24,6 +24,7 @@ multiple clients such as openlayers and google maps rather than to give you all functionality that KML on google earth provides. """ + from fastkml.about import __version__ # noqa: F401 from fastkml.atom import Author as AtomAuthor from fastkml.atom import Contributor as AtomContributor diff --git a/fastkml/atom.py b/fastkml/atom.py index 55548144..8086fb63 100644 --- a/fastkml/atom.py +++ b/fastkml/atom.py @@ -42,6 +42,7 @@ from fastkml.base import _XMLObject from fastkml.helpers import attribute_int_kwarg from fastkml.helpers import attribute_text_kwarg +from fastkml.helpers import clean_string from fastkml.helpers import int_attribute from fastkml.helpers import subelement_text_kwarg from fastkml.helpers import text_attribute @@ -82,11 +83,11 @@ class Link(_AtomObject): title, and length. """ - href: str - rel: str - type: str - hreflang: str - title: str + href: Optional[str] + rel: Optional[str] + type: Optional[str] + hreflang: Optional[str] + title: Optional[str] length: Optional[int] def __init__( @@ -133,11 +134,11 @@ def __init__( """ super().__init__(ns=ns, name_spaces=name_spaces, **kwargs) - self.href = href or "" - self.rel = rel or "" - self.type = type or "" - self.hreflang = hreflang or "" - self.title = title or "" + self.href = clean_string(href) + self.rel = clean_string(rel) + self.type = clean_string(type) + self.hreflang = clean_string(hreflang) + self.title = clean_string(title) self.length = length def __repr__(self) -> str: @@ -285,9 +286,9 @@ def __init__( """ super().__init__(ns=ns, name_spaces=name_spaces, **kwargs) - 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 + self.name = clean_string(name) + self.uri = clean_string(uri) + self.email = clean_string(email) def __repr__(self) -> str: """ diff --git a/fastkml/base.py b/fastkml/base.py index f67f7de1..b876d5eb 100644 --- a/fastkml/base.py +++ b/fastkml/base.py @@ -15,6 +15,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Abstract base classes.""" + import logging from typing import Any from typing import Dict @@ -28,6 +29,7 @@ from fastkml.enums import Verbosity from fastkml.registry import registry from fastkml.types import Element +from fastkml.validator import validate logger = logging.getLogger(__name__) @@ -204,6 +206,23 @@ def to_string( ), ) + def validate(self) -> Optional[bool]: + """ + Validate the KML object against the XML schema. + + Returns + ------- + Optional[bool] + True if the object is valid, None if the XMLSchema is not available. + + Raises + ------ + AssertionError + If the object is not valid. + + """ + return validate(element=self.etree_element()) + def _get_splat(self) -> Dict[str, Any]: """ Get the keyword arguments as a dictionary. diff --git a/fastkml/config.py b/fastkml/config.py index 4756d01c..cd4b7ea1 100644 --- a/fastkml/config.py +++ b/fastkml/config.py @@ -15,6 +15,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Frequently used constants and configuration options.""" + import logging import warnings from types import ModuleType @@ -36,7 +37,7 @@ except ImportError: # pragma: no cover warnings.warn("Package `lxml` missing. Pretty print will be disabled") # noqa: B028 - import xml.etree.ElementTree as etree # noqa: N813 + import xml.etree.ElementTree as etree # noqa: N813, ICN001 logger = logging.getLogger(__name__) diff --git a/fastkml/containers.py b/fastkml/containers.py index 95dee533..5041d219 100644 --- a/fastkml/containers.py +++ b/fastkml/containers.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 """Container classes for KML elements.""" + import logging import urllib.parse as urlparse from typing import Any @@ -27,6 +28,7 @@ from fastkml import gx from fastkml.data import ExtendedData from fastkml.data import Schema +from fastkml.features import NetworkLink from fastkml.features import Placemark from fastkml.features import Snippet from fastkml.features import _Feature @@ -37,6 +39,8 @@ from fastkml.geometry import Polygon from fastkml.helpers import xml_subelement_list from fastkml.helpers import xml_subelement_list_kwarg +from fastkml.overlays import GroundOverlay +from fastkml.overlays import PhotoOverlay from fastkml.registry import RegistryItem from fastkml.registry import registry from fastkml.styles import Style @@ -165,6 +169,8 @@ class Folder(_Container): A Folder is used to arrange other Features hierarchically. It may contain Folders, Placemarks, NetworkLinks, or Overlays. + + https://developers.google.com/kml/documentation/kmlreference#folder """ @@ -199,7 +205,7 @@ def __init__( styles: Optional[Iterable[Union[Style, StyleMap]]] = None, region: Optional[Region] = None, extended_data: Optional[ExtendedData] = None, - features: Optional[List[_Feature]] = None, + features: Optional[Iterable[_Feature]] = None, schemata: Optional[Iterable[Schema]] = None, **kwargs: Any, ) -> None: @@ -322,8 +328,8 @@ def get_style_by_url(self, style_url: str) -> Optional[Union[Style, StyleMap]]: RegistryItem( ns_ids=("kml",), attr_name="features", - node_name="Folder,Placemark,Document", - classes=(Folder, Placemark, Document), + node_name="Folder,Placemark,Document,GroundOverlay,PhotoOverlay,NetworkLink", + classes=(Document, Folder, Placemark, GroundOverlay, PhotoOverlay, NetworkLink), get_kwarg=xml_subelement_list_kwarg, set_element=xml_subelement_list, ), diff --git a/fastkml/data.py b/fastkml/data.py index 49c1169f..baea3587 100644 --- a/fastkml/data.py +++ b/fastkml/data.py @@ -32,6 +32,7 @@ from fastkml.exceptions import KMLSchemaError from fastkml.helpers import attribute_enum_kwarg from fastkml.helpers import attribute_text_kwarg +from fastkml.helpers import clean_string from fastkml.helpers import enum_attribute from fastkml.helpers import node_text from fastkml.helpers import node_text_kwarg @@ -116,9 +117,9 @@ def __init__( name_spaces=name_spaces, **kwargs, ) - self.name = name.strip() or None if name else None + self.name = clean_string(name) self.type_ = type_ or None - self.display_name = display_name.strip() or None if display_name else None + self.display_name = clean_string(display_name) def __repr__(self) -> str: """ @@ -246,9 +247,9 @@ def __init__( name_spaces=name_spaces, **kwargs, ) - self.name = name.strip() or None if name else None + self.name = clean_string(name) self.fields = list(fields) if fields else [] - self.id = id.strip() or None if id else None + self.id = clean_string(id) def __repr__(self) -> str: """ @@ -364,9 +365,9 @@ def __init__( target_id=target_id, **kwargs, ) - 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 + self.name = clean_string(name) + self.value = clean_string(value) + self.display_name = clean_string(display_name) def __repr__(self) -> str: """ @@ -471,8 +472,8 @@ def __init__( name_spaces=name_spaces, **kwargs, ) - self.name = name.strip() or None if name else None - self.value = value.strip() or None if value else None + self.name = clean_string(name) + self.value = clean_string(value) def __repr__(self) -> str: """ @@ -579,7 +580,7 @@ def __init__( target_id=target_id, **kwargs, ) - self.schema_url = schema_url.strip() or None if schema_url else None + self.schema_url = clean_string(schema_url) self.data = list(data) if data else [] def __repr__(self) -> str: diff --git a/fastkml/enums.py b/fastkml/enums.py index d75ff298..75de9ebb 100644 --- a/fastkml/enums.py +++ b/fastkml/enums.py @@ -21,6 +21,7 @@ https://developers.google.com/kml/documentation/kmlreference#kml-fields """ + import logging from enum import Enum from enum import unique diff --git a/fastkml/features.py b/fastkml/features.py index 1a204ad8..6a4c729b 100644 --- a/fastkml/features.py +++ b/fastkml/features.py @@ -44,6 +44,7 @@ from fastkml.geometry import create_kml_geometry from fastkml.helpers import attribute_int_kwarg from fastkml.helpers import bool_subelement +from fastkml.helpers import clean_string from fastkml.helpers import int_attribute from fastkml.helpers import node_text from fastkml.helpers import node_text_kwarg @@ -133,7 +134,7 @@ def __init__( """ super().__init__(ns=ns, name_spaces=name_spaces, **kwargs) - self.text = text.strip() or None if text else None + self.text = clean_string(text) self.max_lines = max_lines def __repr__(self) -> str: @@ -286,8 +287,8 @@ def __init__( target_id=target_id, **kwargs, ) - self.name = name - self.description = description.strip() or None if description else None + self.name = clean_string(name) + self.description = clean_string(description) self.style_url = style_url self.styles = list(styles) if styles else [] self.view = view @@ -296,8 +297,8 @@ def __init__( self.snippet = snippet self.atom_author = atom_author self.atom_link = atom_link - self.address = address - self.phone_number = phone_number + self.address = clean_string(address) + self.phone_number = clean_string(phone_number) self.region = region self.extended_data = extended_data self.times = times @@ -693,7 +694,7 @@ def geometry(self) -> Optional[AnyGeometryType]: registry.register( Placemark, RegistryItem( - ns_ids=("kml",), + ns_ids=("kml", "gx"), attr_name="kml_geometry", node_name=( "Point,LineString,LinearRing,Polygon,MultiGeometry," diff --git a/fastkml/geometry.py b/fastkml/geometry.py index 50dd48e5..d6853b59 100644 --- a/fastkml/geometry.py +++ b/fastkml/geometry.py @@ -425,7 +425,7 @@ def __init__( """ if geometry is not None and kml_coordinates is not None: raise GeometryError(MsgMutualExclusive) - if kml_coordinates is None: + if kml_coordinates is None and geometry: kml_coordinates = ( Coordinates(coords=geometry.coords) # type: ignore[arg-type] if geometry diff --git a/fastkml/gx.py b/fastkml/gx.py index 736814a7..f980bbba 100644 --- a/fastkml/gx.py +++ b/fastkml/gx.py @@ -77,6 +77,7 @@ The complete XML schema for elements in this extension namespace is located at http://developers.google.com/kml/schema/kml22gx.xsd. """ + import logging from dataclasses import dataclass from itertools import zip_longest diff --git a/fastkml/helpers.py b/fastkml/helpers.py index c2104a6d..2f121b66 100644 --- a/fastkml/helpers.py +++ b/fastkml/helpers.py @@ -20,6 +20,7 @@ from typing import TYPE_CHECKING from typing import Any from typing import Dict +from typing import Iterable from typing import List from typing import Optional from typing import Tuple @@ -33,6 +34,44 @@ from fastkml.exceptions import KMLParseError from fastkml.types import Element +__all__ = [ + "attribute_enum_kwarg", + "attribute_float_kwarg", + "attribute_int_kwarg", + "attribute_text_kwarg", + "bool_subelement", + "clean_string", + "coords_subelement_list", + "coords_subelement_list_kwarg", + "datetime_subelement", + "datetime_subelement_kwarg", + "datetime_subelement_list", + "datetime_subelement_list_kwarg", + "enum_attribute", + "enum_subelement", + "float_attribute", + "float_subelement", + "get_coord_args", + "get_ns", + "get_value", + "handle_error", + "int_attribute", + "int_subelement", + "node_text", + "node_text_kwarg", + "subelement_bool_kwarg", + "subelement_enum_kwarg", + "subelement_float_kwarg", + "subelement_int_kwarg", + "subelement_text_kwarg", + "text_attribute", + "text_subelement", + "xml_subelement", + "xml_subelement_kwarg", + "xml_subelement_list", + "xml_subelement_list_kwarg", +] + if TYPE_CHECKING: from fastkml.base import _XMLObject from fastkml.times import KmlDateTime @@ -41,6 +80,11 @@ logger = logging.getLogger(__name__) +def clean_string(value: Optional[str]) -> Optional[str]: + """Clean and validate a string value, returning None if empty.""" + return value.strip() or None if value else None + + def handle_error( *, error: Exception, @@ -1099,6 +1143,29 @@ def datetime_subelement_list_kwarg( return {kwarg: args_list} if args_list else {} +def get_coord_args( + element: Element, + subelements: Iterable[Element], + strict: bool, # noqa: FBT001 +) -> Iterable[PointType]: + """Extract a list of KML coordinate values from subelements of an XML element.""" + for subelement in subelements: + if subelement.text: + try: + yield cast( + PointType, + tuple(float(coord) for coord in subelement.text.split()), + ) + except ValueError as exc: + handle_error( + error=exc, + strict=strict, + element=element, + node=subelement, + expected="Coordinates", + ) + + def coords_subelement_list_kwarg( *, element: Element, @@ -1112,22 +1179,7 @@ def coords_subelement_list_kwarg( """Extract a list of KML coordinate values from subelements of an XML element.""" args_list: List[PointType] = [] if subelements := element.findall(f"{ns}{node_name}"): - for subelement in subelements: - if subelement.text: - try: - coords = cast( - PointType, - tuple(float(coord) for coord in subelement.text.split()), - ) - args_list.append(coords) - except ValueError as exc: - handle_error( - error=exc, - strict=strict, - element=element, - node=subelement, - expected="Coordinates", - ) + args_list = list(get_coord_args(element, subelements, strict)) return {kwarg: args_list} if args_list else {} diff --git a/fastkml/kml.py b/fastkml/kml.py index 7c5dab89..57df7d04 100644 --- a/fastkml/kml.py +++ b/fastkml/kml.py @@ -25,6 +25,7 @@ http://schemas.opengis.net/kml/. """ + import logging from pathlib import Path from typing import IO @@ -40,10 +41,12 @@ from typing_extensions import Self from fastkml import config +from fastkml import validator from fastkml.base import _XMLObject from fastkml.containers import Document from fastkml.containers import Folder from fastkml.enums import Verbosity +from fastkml.features import NetworkLink from fastkml.features import Placemark from fastkml.helpers import xml_subelement_list from fastkml.helpers import xml_subelement_list_kwarg @@ -58,6 +61,44 @@ kml_children = Union[Folder, Document, Placemark, GroundOverlay, PhotoOverlay] +def lxml_parse_and_validate( + file: Union[Path, str, IO[AnyStr]], + strict: bool, # noqa: FBT001 + validate: Optional[bool], +) -> Element: + """ + Parse and validate a KML file using lxml. + + Args: + ---- + file: The file to parse. + Can be a file path (str or Path), or a file-like object. + strict (bool): Whether to enforce strict parsing rules. + validate (Optional[bool]): Whether to validate the file against the schema. + + Returns: + ------- + Element: The root element of the parsed KML file. + + Raises: + ------ + TypeError: If lxml is not available. + + """ + if strict and validate is None: + validate = True + tree = config.etree.parse( + file, + parser=config.etree.XMLParser( + huge_tree=True, + recover=True, + ), + ) + if validate: + validator.validate(element=tree) + return cast(Element, tree.getroot()) + + class KML(_XMLObject): """represents a KML File.""" @@ -163,6 +204,7 @@ def parse( ns: Optional[str] = None, name_spaces: Optional[Dict[str, str]] = None, strict: bool = True, + validate: Optional[bool] = None, ) -> Self: """ Parse a KML file and return a KML object. @@ -178,6 +220,7 @@ def parse( If not provided, it will be inferred from the root element. name_spaces (Optional[Dict[str, str]]): Additional namespaces. strict (bool): Whether to enforce strict parsing rules. Defaults to True. + validate (Optional[bool]): Whether to validate the file against the schema. Returns: ------- @@ -185,18 +228,11 @@ def parse( """ try: - tree = config.etree.parse( - file, - parser=config.etree.XMLParser( - huge_tree=True, - recover=True, - ), - ) + root = lxml_parse_and_validate(file, strict, validate) except TypeError: - tree = config.etree.parse(file) - root = tree.getroot() + root = config.etree.parse(file).getroot() if ns is None: - ns = cast(str, root.tag[:-3] if root.tag.endswith("kml") else "") + ns = root.tag[:-3] if root.tag.endswith("kml") else "" name_spaces = name_spaces or {} if ns: name_spaces["kml"] = ns @@ -213,8 +249,8 @@ def parse( KML, RegistryItem( ns_ids=("kml",), - classes=(Document, Folder, Placemark, GroundOverlay, PhotoOverlay), - node_name="Document,Folder,Placemark,GroundOverlay,PhotoOverlay", + classes=(Document, Folder, Placemark, GroundOverlay, PhotoOverlay, NetworkLink), + node_name="Document,Folder,Placemark,GroundOverlay,PhotoOverlay,NetworkLink", attr_name="features", get_kwarg=xml_subelement_list_kwarg, set_element=xml_subelement_list, diff --git a/fastkml/kml_base.py b/fastkml/kml_base.py index 55139afd..adaa5cad 100644 --- a/fastkml/kml_base.py +++ b/fastkml/kml_base.py @@ -15,6 +15,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Abstract base classes.""" + from typing import Any from typing import Dict from typing import Optional diff --git a/fastkml/links.py b/fastkml/links.py index 32222b14..a0180bf7 100644 --- a/fastkml/links.py +++ b/fastkml/links.py @@ -21,6 +21,7 @@ from fastkml.enums import RefreshMode from fastkml.enums import ViewRefreshMode +from fastkml.helpers import clean_string from fastkml.helpers import enum_subelement from fastkml.helpers import float_subelement from fastkml.helpers import subelement_enum_kwarg @@ -45,14 +46,14 @@ class Link(_BaseObject): https://developers.google.com/kml/documentation/kmlreference#link """ - href: str + href: Optional[str] refresh_mode: Optional[RefreshMode] refresh_interval: Optional[float] view_refresh_mode: Optional[ViewRefreshMode] view_refresh_time: Optional[float] view_bound_scale: Optional[float] - view_format: str - http_query: str + view_format: Optional[str] + http_query: Optional[str] def __init__( self, @@ -78,14 +79,14 @@ def __init__( target_id=target_id, **kwargs, ) - self.href = href.strip() if href else "" + self.href = clean_string(href) self.refresh_mode = refresh_mode self.refresh_interval = refresh_interval self.view_refresh_mode = view_refresh_mode self.view_refresh_time = view_refresh_time self.view_bound_scale = view_bound_scale - self.view_format = view_format or "" - self.http_query = http_query or "" + self.view_format = clean_string(view_format) + self.http_query = clean_string(http_query) def __repr__(self) -> str: """Create a string (c)representation for Link.""" diff --git a/fastkml/mixins.py b/fastkml/mixins.py index 4f5c6db7..2d5c9466 100644 --- a/fastkml/mixins.py +++ b/fastkml/mixins.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 """Mixins for the KML classes.""" + import logging from typing import Optional from typing import Union diff --git a/fastkml/overlays.py b/fastkml/overlays.py index d5ea5ed3..61dbe729 100644 --- a/fastkml/overlays.py +++ b/fastkml/overlays.py @@ -37,6 +37,7 @@ from fastkml.geometry import MultiGeometry from fastkml.geometry import Point from fastkml.geometry import Polygon +from fastkml.helpers import clean_string from fastkml.helpers import enum_subelement from fastkml.helpers import float_subelement from fastkml.helpers import int_subelement @@ -203,7 +204,7 @@ def __init__( extended_data=extended_data, ) self.icon = icon - self.color = color + self.color = clean_string(color) self.draw_order = draw_order def __repr__(self) -> str: @@ -468,6 +469,8 @@ class ImagePyramid(_XMLObject): When you specify an image pyramid, you also need to modify the in the element to include specifications for which tiles to load. + + https://developers.google.com/kml/documentation/kmlreference#imagepyramid """ _default_nsid = config.KML @@ -552,12 +555,7 @@ def __bool__(self) -> bool: bool: True if all the required attributes are set, False otherwise. """ - return ( - self.tile_size is not None - and self.max_width is not None - and self.max_height is not None - and self.grid_origin is not None - ) + return self.max_width is not None and self.max_height is not None registry.register( @@ -1266,7 +1264,7 @@ def __repr__(self) -> str: registry.register( GroundOverlay, RegistryItem( - ns_ids=("kml",), + ns_ids=("kml", "gx", ""), attr_name="altitude_mode", node_name="altitudeMode", classes=(AltitudeMode,), diff --git a/fastkml/registry.py b/fastkml/registry.py index 6dd36bb5..e7604908 100644 --- a/fastkml/registry.py +++ b/fastkml/registry.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 """Registry for XML objects.""" + from dataclasses import dataclass from typing import TYPE_CHECKING from typing import Any diff --git a/fastkml/styles.py b/fastkml/styles.py index 763f0888..254cb420 100644 --- a/fastkml/styles.py +++ b/fastkml/styles.py @@ -39,6 +39,7 @@ from fastkml.helpers import attribute_enum_kwarg from fastkml.helpers import attribute_float_kwarg from fastkml.helpers import bool_subelement +from fastkml.helpers import clean_string from fastkml.helpers import enum_attribute from fastkml.helpers import enum_subelement from fastkml.helpers import float_attribute @@ -108,7 +109,7 @@ def __init__( name_spaces=name_spaces, **kwargs, ) - self.url = url + self.url = clean_string(url) def __repr__(self) -> str: """Create a string (c)representation for StyleUrl.""" @@ -221,7 +222,7 @@ def __init__( target_id=target_id, **kwargs, ) - self.color = color + self.color = clean_string(color) self.color_mode = color_mode def __repr__(self) -> str: @@ -474,7 +475,7 @@ def __init__( color_mode=color_mode, **kwargs, ) - icon_href = icon_href.strip() or None if icon_href else None + icon_href = clean_string(icon_href) self.scale = scale self.heading = heading if icon_href and icon: @@ -986,9 +987,9 @@ def __init__( target_id=target_id, **kwargs, ) - self.bg_color = bg_color - self.text_color = text_color - self.text = text.strip() or None if text else None + self.bg_color = clean_string(bg_color) + self.text_color = clean_string(text_color) + self.text = clean_string(text) self.display_mode = display_mode def __repr__(self) -> str: diff --git a/fastkml/times.py b/fastkml/times.py index e33c71b3..093902df 100644 --- a/fastkml/times.py +++ b/fastkml/times.py @@ -22,6 +22,7 @@ https://developers.google.com/kml/documentation/time """ + import re from datetime import date from datetime import datetime @@ -40,6 +41,8 @@ from fastkml.registry import RegistryItem from fastkml.registry import registry +__all__ = ["KmlDateTime", "TimeSpan", "TimeStamp", "adjust_date_to_resolution"] + # 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 @@ -48,6 +51,45 @@ ) +def adjust_date_to_resolution( + dt: Union[date, datetime], + resolution: Optional[DateTimeResolution] = None, +) -> Union[date, datetime]: + """ + Adjust the date or datetime to the specified resolution. + + This function adjusts the date or datetime to the specified resolution. + If the resolution is not specified, the function will return the date or + datetime as is. + + The function will return the date if the resolution is set to year, + year_month, or date. If the resolution is set to datetime, the function + will return the datetime as is. + + Args: + ---- + dt : Union[date, datetime] + The date or datetime to adjust. + resolution : Optional[DateTimeResolution], optional + The resolution to adjust the date or datetime to, by default None. + + Returns: + ------- + Union[date, datetime] + The adjusted date or datetime. + + """ + if resolution == DateTimeResolution.year: + return date(dt.year, 1, 1) + if resolution == DateTimeResolution.year_month: + return date(dt.year, dt.month, 1) + return ( + dt.date() + if isinstance(dt, datetime) and resolution != DateTimeResolution.datetime + else dt + ) + + class KmlDateTime: """ A KML DateTime object. @@ -87,7 +129,17 @@ def __init__( dt: Union[date, datetime], resolution: Optional[DateTimeResolution] = None, ) -> None: - """Initialize a KmlDateTime object.""" + """ + Initialize a KmlDateTime object. + + Args: + ---- + dt : Union[date, datetime] + The date or datetime to adjust. + resolution : Optional[DateTimeResolution], optional + The resolution to adjust the date or datetime to, by default None. + + """ if resolution is None: # sourcery skip: swap-if-expression resolution = ( @@ -95,17 +147,7 @@ def __init__( 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.dt = adjust_date_to_resolution(dt, resolution) self.resolution = ( DateTimeResolution.date if not isinstance(self.dt, datetime) diff --git a/fastkml/types.py b/fastkml/types.py index 7368d394..24f7543d 100644 --- a/fastkml/types.py +++ b/fastkml/types.py @@ -30,6 +30,7 @@ # along with this library; if not, write to the Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Types for fastkml.""" + from typing import Iterable from typing import Optional diff --git a/fastkml/utils.py b/fastkml/utils.py index 7616c610..70cf5abc 100644 --- a/fastkml/utils.py +++ b/fastkml/utils.py @@ -30,14 +30,14 @@ def has_attribute_values(obj: object, **kwargs: Any) -> bool: return False -def find_all( +def find_in( obj: object, *, of_type: Optional[Union[Type[object], Tuple[Type[object], ...]]] = None, **kwargs: Any, ) -> Generator[object, None, None]: """ - Find all instances of a given type in a given object. + Find all instances of type in the attributes of an object. Args: ---- @@ -50,11 +50,6 @@ def find_all( An iterable of all instances of the given type in the given object. """ - if (of_type is None or isinstance(obj, of_type)) and has_attribute_values( - obj, - **kwargs, - ): - yield obj try: attrs = (attr for attr in obj.__dict__ if not attr.startswith("_")) except AttributeError: @@ -68,6 +63,34 @@ def find_all( yield from find_all(attr, of_type=of_type, **kwargs) +def find_all( + obj: object, + *, + of_type: Optional[Union[Type[object], Tuple[Type[object], ...]]] = None, + **kwargs: Any, +) -> Generator[object, None, None]: + """ + Find all instances of a given type with attributes matching the kwargs. + + Args: + ---- + obj: The object to search. + of_type: The type(s) to search for or None for any type. + **kwargs: Attributes of the object to match. + + Returns: + ------- + An iterable of all instances of the given type in the given object. + + """ + if (of_type is None or isinstance(obj, of_type)) and has_attribute_values( + obj, + **kwargs, + ): + yield obj + yield from find_in(obj, of_type=of_type, **kwargs) + + def find( obj: object, *, diff --git a/fastkml/validator.py b/fastkml/validator.py index 5c1f8c8e..404d625d 100644 --- a/fastkml/validator.py +++ b/fastkml/validator.py @@ -18,12 +18,25 @@ import logging import pathlib from functools import lru_cache +from typing import TYPE_CHECKING from typing import Final from typing import Optional from fastkml import config from fastkml.types import Element +if TYPE_CHECKING: + import contextlib + + with contextlib.suppress(ImportError): + from lxml import etree + +__all__ = [ + "get_schema_parser", + "validate", +] + + logger = logging.getLogger(__name__) MUTUAL_EXCLUSIVE: Final = "Only one of element and file_to_validate can be provided." @@ -33,7 +46,7 @@ @lru_cache(maxsize=16) def get_schema_parser( schema: Optional[pathlib.Path] = None, -) -> "config.etree.XMLSchema": +) -> "etree.XMLSchema": """ Parse the XML schema. @@ -53,6 +66,43 @@ def get_schema_parser( return config.etree.XMLSchema(config.etree.parse(schema)) +def handle_validation_error( + schema_parser: "etree.XMLSchema", + element: Element, +) -> None: + """ + Log the validation error in its XML context. + + Args: + ---- + schema_parser: The parsed XML schema. + element: The element to validate. + + """ + log = schema_parser.error_log + for error_entry in log: + try: + parent = element.xpath(error_entry.path)[ # type: ignore[attr-defined] + 0 + ].getparent() + except config.etree.XPathEvalError: + parent = element + if parent is None: + parent = element + error_in_xml = config.etree.tostring( + parent, + encoding="UTF-8", + pretty_print=True, + ).decode( + "UTF-8", + ) + logger.error( + "Error <%s> in XML:\n %s", + error_entry.message, + error_in_xml, + ) + + def validate( *, schema: Optional[pathlib.Path] = None, @@ -87,31 +137,10 @@ def validate( if file_to_validate is not None: element = config.etree.parse(file_to_validate) - + assert element is not None # noqa: S101 try: schema_parser.assert_(element) # noqa: PT009 except AssertionError: - log = schema_parser.error_log - for e in log: - try: - parent = element.xpath(e.path)[ # type: ignore[union-attr] - 0 - ].getparent() - except config.etree.XPathEvalError: - parent = element - if parent is None: - parent = element - error_in_xml = config.etree.tostring( - parent, - encoding="UTF-8", - pretty_print=True, - ).decode( - "UTF-8", - ) - logger.error( # noqa: TRY400 - "Error <%s> in XML:\n %s", - e.message, - error_in_xml, - ) + handle_validation_error(schema_parser, element) raise return True diff --git a/fastkml/views.py b/fastkml/views.py index 97b3ea66..9a6b5297 100644 --- a/fastkml/views.py +++ b/fastkml/views.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 """KML Views.""" + import logging from typing import Any from typing import Dict diff --git a/pyproject.toml b/pyproject.toml index f45254c9..674d2541 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,12 +60,10 @@ docs = [ linting = [ "black", "flake8", + "flake8-cognitive-complexity", "flake8-comments", "flake8-encodings", "flake8-expression-complexity", - "flake8-length", - "flake8-pep3101", - "flake8-super", "flake8-typing-imports", "ruff", "yamllint", @@ -251,6 +249,11 @@ include-package-data = true [tool.setuptools.dynamic.version] attr = "fastkml.about.__version__" +[tool.setuptools.package-data] +fastkml = [ + "schema/*.xsd", +] + [tool.setuptools.packages.find] exclude = [ "docs/*", diff --git a/tests/atom_test.py b/tests/atom_test.py index c243a4ae..3c0df4bf 100644 --- a/tests/atom_test.py +++ b/tests/atom_test.py @@ -89,7 +89,7 @@ def test_atom_link_read_no_href(self) -> None: 'rel="alternate" type="text/html" hreflang="en" ' 'title="Title" length="3456" />', ) - assert link.href == "" + assert link.href is None def test_atom_person_ns(self) -> None: ns = "{http://www.opengis.net/kml/2.2}" diff --git a/tests/base.py b/tests/base.py index 250b2480..925c1a65 100644 --- a/tests/base.py +++ b/tests/base.py @@ -15,6 +15,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Base classes to run the tests both with the std library and lxml.""" + import xml.etree.ElementTree as ET import pytest diff --git a/tests/base_test.py b/tests/base_test.py index 8388c665..f1cf5faa 100644 --- a/tests/base_test.py +++ b/tests/base_test.py @@ -16,7 +16,6 @@ """Test the base classes.""" - from fastkml import base from fastkml import kml_base from tests.base import Lxml diff --git a/tests/containers_test.py b/tests/containers_test.py index c7890253..60e5cf30 100644 --- a/tests/containers_test.py +++ b/tests/containers_test.py @@ -16,7 +16,6 @@ """Test the kml classes.""" - import pytest from fastkml import containers diff --git a/tests/features_test.py b/tests/features_test.py index 8384ae5d..441a0235 100644 --- a/tests/features_test.py +++ b/tests/features_test.py @@ -16,7 +16,10 @@ """Test the kml classes.""" +import datetime + import pytest +from dateutil.tz import tzutc from pygeoif import geometry as geo from fastkml import atom @@ -24,6 +27,7 @@ from fastkml import geometry from fastkml import links from fastkml import styles +from fastkml import times from fastkml import views from tests.base import Lxml from tests.base import StdLibrary @@ -46,6 +50,49 @@ def test_feature_base(self) -> None: assert f.times is None assert "_Feature>" in str(f.to_string()) + def test_placemark_empty_str_roundtrip(self) -> None: + pm = features.Placemark() + + new_pm = features.Placemark.from_string(str(pm.to_string())) + + assert new_pm == pm + + def test_placemark_camera_str_roundtrip(self) -> None: + camera = views.Camera( + latitude=37.0, + longitude=-122.0, + altitude=0.0, + roll=0.0, + tilt=0.0, + heading=0.0, + ) + pm = features.Placemark(view=camera) + + new_pm = features.Placemark.from_string(str(pm.to_string())) + + assert new_pm == pm + + def test_placemark_timespan_str_roundtrip(self) -> None: + time_span = times.TimeSpan( + begin=times.KmlDateTime( + dt=datetime.datetime( + 2012, + 3, + 5, + 0, + 48, + 32, + tzinfo=tzutc(), + ), + ), + end=times.KmlDateTime(dt=datetime.date(2012, 4, 5)), + ) + pm = features.Placemark(times=time_span) + + new_pm = features.Placemark.from_string(str(pm.to_string())) + + assert new_pm == pm + def test_placemark_geometry_parameter_set(self) -> None: """Placemark object can be created with geometry parameter.""" geometry = geo.Point(10, 20) diff --git a/tests/geometries/coordinates_test.py b/tests/geometries/coordinates_test.py index 745186e2..0a464551 100644 --- a/tests/geometries/coordinates_test.py +++ b/tests/geometries/coordinates_test.py @@ -16,7 +16,6 @@ """Test the coordinates class.""" - from fastkml.geometry import Coordinates from tests.base import Lxml from tests.base import StdLibrary diff --git a/tests/geometries/functions_test.py b/tests/geometries/functions_test.py index ae89da26..6daa3236 100644 --- a/tests/geometries/functions_test.py +++ b/tests/geometries/functions_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 geometry error handling.""" + from typing import Callable from unittest.mock import Mock from unittest.mock import patch diff --git a/tests/geometries/geometry_test.py b/tests/geometries/geometry_test.py index 224b9a1c..9636d21f 100644 --- a/tests/geometries/geometry_test.py +++ b/tests/geometries/geometry_test.py @@ -15,6 +15,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Test the geometry classes.""" + import pytest from pygeoif import geometry as geo diff --git a/tests/geometries/multigeometry_test.py b/tests/geometries/multigeometry_test.py index bdcc182c..70458f7a 100644 --- a/tests/geometries/multigeometry_test.py +++ b/tests/geometries/multigeometry_test.py @@ -15,6 +15,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Test the geometry classes.""" + import pygeoif.geometry as geo import pytest diff --git a/tests/gx_test.py b/tests/gx_test.py index 8ad064d6..c831ba3a 100644 --- a/tests/gx_test.py +++ b/tests/gx_test.py @@ -15,6 +15,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Test the gx classes.""" + import datetime import pygeoif.geometry as geo diff --git a/tests/helper_test.py b/tests/helper_test.py index cd1ae8b7..3654fd81 100644 --- a/tests/helper_test.py +++ b/tests/helper_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 helper functions edge cases.""" + from enum import Enum from typing import Callable from unittest.mock import Mock diff --git a/tests/hypothesis/atom_test.py b/tests/hypothesis/atom_test.py index 4cf0cbe8..32c7b610 100644 --- a/tests/hypothesis/atom_test.py +++ b/tests/hypothesis/atom_test.py @@ -27,7 +27,7 @@ from hypothesis import strategies as st from hypothesis.provisional import urls -import fastkml +import fastkml.atom import fastkml.enums from tests.base import Lxml from tests.hypothesis.common import assert_repr_roundtrip @@ -40,7 +40,6 @@ class TestLxml(Lxml): - @given( href=urls(), rel=st.one_of(st.none(), xml_text()), diff --git a/tests/hypothesis/common.py b/tests/hypothesis/common.py index 639676ff..c76d8420 100644 --- a/tests/hypothesis/common.py +++ b/tests/hypothesis/common.py @@ -36,14 +36,15 @@ from fastkml.enums import DataType from fastkml.enums import DateTimeResolution from fastkml.enums import DisplayMode +from fastkml.enums import GridOrigin from fastkml.enums import PairKey from fastkml.enums import RefreshMode +from fastkml.enums import Shape from fastkml.enums import Units from fastkml.enums import Verbosity from fastkml.enums import ViewRefreshMode from fastkml.gx import Angle from fastkml.gx import TrackItem -from fastkml.validator import validate logger = logging.getLogger(__name__) @@ -69,6 +70,8 @@ "ColorMode": ColorMode, "DisplayMode": DisplayMode, "PairKey": PairKey, + "GridOrigin": GridOrigin, + "Shape": Shape, "tzutc": tzutc, "tzfile": tzfile, } @@ -93,7 +96,7 @@ def assert_str_roundtrip(obj: _XMLObject) -> None: assert obj.to_string() == new_object.to_string() assert obj == new_object - assert validate(element=new_object.etree_element()) + assert new_object.validate() def assert_str_roundtrip_terse(obj: _XMLObject) -> None: @@ -104,7 +107,7 @@ def assert_str_roundtrip_terse(obj: _XMLObject) -> None: assert obj.to_string(verbosity=Verbosity.verbose) == new_object.to_string( verbosity=Verbosity.verbose, ) - assert validate(element=new_object.etree_element()) + assert new_object.validate() def assert_str_roundtrip_verbose(obj: _XMLObject) -> None: @@ -115,4 +118,4 @@ def assert_str_roundtrip_verbose(obj: _XMLObject) -> None: assert obj.to_string(verbosity=Verbosity.terse) == new_object.to_string( verbosity=Verbosity.terse, ) - assert validate(element=new_object.etree_element()) + assert new_object.validate() diff --git a/tests/hypothesis/container_test.py b/tests/hypothesis/container_test.py new file mode 100644 index 00000000..28cbfce7 --- /dev/null +++ b/tests/hypothesis/container_test.py @@ -0,0 +1,153 @@ +# 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 itertools +import typing + +from hypothesis import given +from hypothesis import strategies as st +from hypothesis.provisional import urls + +import fastkml.containers +import fastkml.data +import fastkml.features +import fastkml.links +import fastkml.overlays +import fastkml.views +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 + + +class TestLxml(Lxml): + @given( + features_tuple=st.tuples( + st.lists( + st.builds( + fastkml.containers.Document, + ), + ), + st.lists( + st.builds( + fastkml.containers.Folder, + ), + ), + st.lists( + st.builds( + fastkml.features.Placemark, + ), + ), + st.lists( + st.builds( + fastkml.overlays.GroundOverlay, + ), + ), + st.lists( + st.builds( + fastkml.overlays.PhotoOverlay, + ), + ), + st.lists( + st.builds( + fastkml.features.NetworkLink, + link=st.builds( + fastkml.links.Link, + href=urls(), + ), + ), + ), + ), + ) + def test_fuzz_folder( + self, + features_tuple: typing.Tuple[typing.Iterable[fastkml.features._Feature]], + ) -> None: + features = itertools.chain(*features_tuple) + folder = fastkml.containers.Folder( + features=features, + ) + + assert_repr_roundtrip(folder) + assert_str_roundtrip(folder) + assert_str_roundtrip_terse(folder) + assert_str_roundtrip_verbose(folder) + + @given( + features_tuple=st.tuples( + st.lists( + st.builds( + fastkml.containers.Document, + ), + ), + st.lists( + st.builds( + fastkml.containers.Folder, + ), + ), + st.lists( + st.builds( + fastkml.features.Placemark, + ), + ), + st.lists( + st.builds( + fastkml.overlays.GroundOverlay, + ), + ), + st.lists( + st.builds( + fastkml.overlays.PhotoOverlay, + ), + ), + st.lists( + st.builds( + fastkml.features.NetworkLink, + link=st.builds( + fastkml.links.Link, + href=urls(), + ), + ), + ), + ), + schemata=st.lists( + st.builds( + fastkml.data.Schema, + id=nc_name(), + ), + max_size=1, + ), + ) + def test_fuzz_document( + self, + features_tuple: typing.Tuple[typing.Iterable[fastkml.features._Feature]], + schemata: typing.Iterable[fastkml.data.Schema], + ) -> None: + features: typing.Iterable[fastkml.features._Feature] = itertools.chain( + *features_tuple, + ) + document = fastkml.containers.Document( + features=features, + schemata=schemata, + ) + + assert_repr_roundtrip(document) + assert_str_roundtrip(document) + assert_str_roundtrip_terse(document) + assert_str_roundtrip_verbose(document) diff --git a/tests/hypothesis/feature_test.py b/tests/hypothesis/feature_test.py new file mode 100644 index 00000000..6f289c45 --- /dev/null +++ b/tests/hypothesis/feature_test.py @@ -0,0 +1,453 @@ +# 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 + +import pygeoif.types +from hypothesis import given +from hypothesis import strategies as st +from hypothesis.provisional import urls + +import fastkml +import fastkml.atom +import fastkml.data +import fastkml.enums +import fastkml.features +import fastkml.gx +import fastkml.links +import fastkml.styles +import fastkml.views +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 geometries +from tests.hypothesis.strategies import kml_datetimes +from tests.hypothesis.strategies import lat_lon_alt_boxes +from tests.hypothesis.strategies import lods +from tests.hypothesis.strategies import nc_name +from tests.hypothesis.strategies import styles +from tests.hypothesis.strategies import track_items +from tests.hypothesis.strategies import xml_text + + +class TestLxml(Lxml): + @given( + text=st.one_of(st.none(), xml_text(min_size=1)), + max_lines=st.one_of( + st.none(), + st.integers(min_value=0, max_value=2_147_483_647), + ), + ) + def test_fuzz_snippet( + self, + text: typing.Optional[str], + max_lines: typing.Optional[int], + ) -> None: + snippet = fastkml.features.Snippet(text=text, max_lines=max_lines) + + assert_repr_roundtrip(snippet) + assert_str_roundtrip(snippet) + assert_str_roundtrip_terse(snippet) + assert_str_roundtrip_verbose(snippet) + + @given( + geometry=st.one_of( + st.none(), + geometries(), + ), + ) + def test_fuzz_placemark_geometry_only( + self, + geometry: typing.Union[ + pygeoif.types.GeoType, + pygeoif.types.GeoCollectionType, + None, + ], + ) -> None: + placemark = fastkml.Placemark( + geometry=geometry, + ) + + assert_repr_roundtrip(placemark) + assert_str_roundtrip(placemark) + assert_str_roundtrip_terse(placemark) + assert_str_roundtrip_verbose(placemark) + + @given( + view=st.one_of( + st.builds( + fastkml.views.Camera, + longitude=st.floats( + allow_nan=False, + allow_infinity=False, + min_value=-180, + max_value=180, + ).filter(lambda x: x != 0), + latitude=st.floats( + allow_nan=False, + allow_infinity=False, + min_value=-90, + max_value=90, + ).filter(lambda x: x != 0), + altitude=st.floats(allow_nan=False, allow_infinity=False).filter( + lambda x: x != 0, + ), + ), + st.builds( + fastkml.views.LookAt, + longitude=st.floats( + allow_nan=False, + allow_infinity=False, + min_value=-180, + max_value=180, + ).filter(lambda x: x != 0), + latitude=st.floats( + allow_nan=False, + allow_infinity=False, + min_value=-90, + max_value=90, + ).filter(lambda x: x != 0), + altitude=st.floats(allow_nan=False, allow_infinity=False).filter( + lambda x: x != 0, + ), + ), + ), + times=st.one_of( + st.builds( + fastkml.TimeStamp, + timestamp=kml_datetimes(), + ), + st.builds( + fastkml.TimeSpan, + begin=kml_datetimes(), + end=kml_datetimes(), + ), + ), + region=st.one_of( + st.none(), + st.builds( + fastkml.views.Region, + lat_lon_alt_box=lat_lon_alt_boxes(), + lod=lods(), + ), + ), + ) + def test_fuzz_placemark_view_times( + self, + view: typing.Union[fastkml.Camera, fastkml.LookAt, None], + times: typing.Union[fastkml.TimeSpan, fastkml.TimeStamp, None], + region: typing.Optional[fastkml.views.Region], + ) -> None: + placemark = fastkml.Placemark( + view=view, + times=times, + region=region, + ) + + assert_repr_roundtrip(placemark) + assert_str_roundtrip(placemark) + assert_str_roundtrip_terse(placemark) + assert_str_roundtrip_verbose(placemark) + + @given( + address=st.one_of( + st.none(), + xml_text(min_size=1).filter(lambda x: x.strip() != ""), + ), + phone_number=st.one_of( + st.none(), + xml_text(min_size=1).filter(lambda x: x.strip() != ""), + ), + snippet=st.one_of( + st.none(), + st.builds( + fastkml.features.Snippet, + text=xml_text(min_size=1, max_size=256).filter( + lambda x: x.strip() != "", + ), + max_lines=st.integers(min_value=1, max_value=20), + ), + ), + description=st.one_of(st.none(), xml_text()), + ) + def test_fuzz_placemark_str( + self, + address: typing.Optional[str], + phone_number: typing.Optional[str], + snippet: typing.Optional[fastkml.features.Snippet], + description: typing.Optional[str], + ) -> None: + placemark = fastkml.Placemark( + address=address, + phone_number=phone_number, + snippet=snippet, + description=description, + ) + + assert_repr_roundtrip(placemark) + assert_str_roundtrip(placemark) + assert_str_roundtrip_terse(placemark) + assert_str_roundtrip_verbose(placemark) + + @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()), + visibility=st.one_of(st.none(), st.booleans()), + isopen=st.one_of(st.none(), st.booleans()), + atom_link=st.one_of( + st.none(), + st.builds( + fastkml.atom.Link, + href=urls(), + ), + ), + atom_author=st.one_of( + st.none(), + st.builds( + fastkml.atom.Author, + name=xml_text(min_size=1, max_size=256).filter( + lambda x: x.strip() != "", + ), + ), + ), + ) + def test_fuzz_placemark_atom( + self, + id: typing.Optional[str], + target_id: typing.Optional[str], + name: typing.Optional[str], + visibility: typing.Optional[bool], + isopen: typing.Optional[bool], + atom_link: typing.Optional[fastkml.atom.Link], + atom_author: typing.Optional[fastkml.atom.Author], + ) -> None: + placemark = fastkml.Placemark( + id=id, + target_id=target_id, + name=name, + visibility=visibility, + isopen=isopen, + atom_link=atom_link, + atom_author=atom_author, + ) + + assert_repr_roundtrip(placemark) + assert_str_roundtrip(placemark) + assert_str_roundtrip_terse(placemark) + assert_str_roundtrip_verbose(placemark) + + @given( + kml_geometry=st.one_of( + st.builds( + fastkml.gx.Track, + altitude_mode=st.sampled_from(fastkml.enums.AltitudeMode), + track_items=st.lists( + track_items(), + min_size=1, + max_size=5, + ), + ), + st.builds( + fastkml.gx.MultiTrack, + altitude_mode=st.one_of( + st.sampled_from(fastkml.enums.AltitudeMode), + ), + tracks=st.lists( + st.builds( + fastkml.gx.Track, + track_items=st.lists( + track_items(), + min_size=1, + max_size=3, + ), + ), + min_size=1, + max_size=3, + ), + ), + ), + ) + def test_fuzz_placemark_gx_track( + self, + kml_geometry: typing.Union[ + fastkml.gx.Track, + fastkml.gx.MultiTrack, + ], + ) -> None: + placemark = fastkml.Placemark( + kml_geometry=kml_geometry, + ) + + assert_repr_roundtrip(placemark) + assert_str_roundtrip(placemark) + assert_str_roundtrip_terse(placemark) + assert_str_roundtrip_verbose(placemark) + + @given( + extended_data=st.builds( + fastkml.ExtendedData, + elements=st.tuples( + 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, + schema_url=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() != ""), + ), + min_size=1, + max_size=3, + ), + ), + ), + ), + ) + def test_fuzz_placemark_extended_data( + self, + extended_data: typing.Optional[fastkml.ExtendedData], + ) -> None: + placemark = fastkml.Placemark( + extended_data=extended_data, + ) + + assert_repr_roundtrip(placemark) + assert_str_roundtrip(placemark) + assert_str_roundtrip_terse(placemark) + assert_str_roundtrip_verbose(placemark) + + @given( + style_url=st.one_of( + st.none(), + st.builds( + fastkml.StyleUrl, + url=urls(), + ), + ), + styles=st.one_of( + st.none(), + st.tuples( + st.builds( + fastkml.Style, + styles=st.lists( + styles(), + min_size=1, + max_size=1, + ), + ), + st.builds( + fastkml.styles.StyleMap, + pairs=st.tuples( + st.builds( + fastkml.styles.Pair, + key=st.just(fastkml.enums.PairKey.normal), + style=st.one_of( + st.builds( + fastkml.StyleUrl, + url=urls(), + ), + st.builds( + fastkml.Style, + styles=st.lists( + styles(), + min_size=1, + max_size=1, + ), + ), + ), + ), + st.builds( + fastkml.styles.Pair, + key=st.just(fastkml.enums.PairKey.highlight), + style=st.one_of( + st.builds( + fastkml.StyleUrl, + url=urls(), + ), + st.builds( + fastkml.Style, + styles=st.lists( + styles(), + min_size=1, + max_size=1, + ), + ), + ), + ), + ), + ), + ), + ), + ) + def test_fuzz_placemark_styles( + self, + style_url: typing.Optional[fastkml.StyleUrl], + styles: typing.Optional[ + typing.Iterable[typing.Union[fastkml.Style, fastkml.StyleMap]] + ], + ) -> None: + placemark = fastkml.Placemark( + style_url=style_url, + styles=styles, + ) + + assert_repr_roundtrip(placemark) + assert_str_roundtrip(placemark) + assert_str_roundtrip_terse(placemark) + assert_str_roundtrip_verbose(placemark) + + @given( + refresh_visibility=st.one_of(st.none(), st.booleans()), + fly_to_view=st.one_of(st.none(), st.booleans()), + link=st.one_of( + st.none(), + st.builds( + fastkml.links.Link, + href=urls(), + ), + ), + ) + def test_network_link( + self, + refresh_visibility: typing.Optional[bool], + fly_to_view: typing.Optional[bool], + link: typing.Optional[fastkml.links.Link], + ) -> None: + """Test NetworkLink object with optional parameters.""" + network_link = fastkml.features.NetworkLink( + refresh_visibility=refresh_visibility, + fly_to_view=fly_to_view, + link=link, + ) + + assert network_link.refresh_visibility == refresh_visibility + assert network_link.fly_to_view == fly_to_view + assert network_link.link == link + + assert_repr_roundtrip(network_link) + assert_str_roundtrip(network_link) + assert_str_roundtrip_terse(network_link) + assert_str_roundtrip_verbose(network_link) diff --git a/tests/hypothesis/geometry_test.py b/tests/hypothesis/geometry_test.py index 22dd8d65..064be7cf 100644 --- a/tests/hypothesis/geometry_test.py +++ b/tests/hypothesis/geometry_test.py @@ -15,6 +15,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Property based tests of the Geometry classes.""" + import typing from functools import partial @@ -135,7 +136,6 @@ def _test_geometry_str_roundtrip_verbose(geometry: kml_geometry) -> None: class TestLxml(Lxml): - @coordinates() @settings(deadline=None) def test_coordinates_str_roundtrip( diff --git a/tests/hypothesis/gx_test.py b/tests/hypothesis/gx_test.py index 8d0a01c7..dcafb9a4 100644 --- a/tests/hypothesis/gx_test.py +++ b/tests/hypothesis/gx_test.py @@ -19,34 +19,18 @@ from hypothesis import given from hypothesis import strategies as st -from pygeoif.hypothesis.strategies import epsg4326 -from pygeoif.hypothesis.strategies import points import fastkml import fastkml.enums import fastkml.gx import fastkml.types -from fastkml.gx import Angle -from fastkml.gx import TrackItem 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( - TrackItem, - angle=st.builds( - Angle, - heading=st.floats(allow_nan=False, allow_infinity=False), - roll=st.floats(allow_nan=False, allow_infinity=False), - tilt=st.floats(allow_nan=False, allow_infinity=False), - ), - coord=points(srs=epsg4326), - when=kml_datetimes(), -) +from tests.hypothesis.strategies import track_items class TestGx(Lxml): @@ -57,7 +41,7 @@ class TestGx(Lxml): track_items=st.one_of( st.none(), st.lists( - track_items, + track_items(), ), ), ) @@ -96,7 +80,7 @@ def test_fuzz_track_track_items( track_items=st.one_of( st.none(), st.lists( - track_items, + track_items(), ), ), ), diff --git a/tests/hypothesis/kml_test.py b/tests/hypothesis/kml_test.py new file mode 100644 index 00000000..d63b8ae8 --- /dev/null +++ b/tests/hypothesis/kml_test.py @@ -0,0 +1,72 @@ +# 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.""" + +from hypothesis import given +from hypothesis import strategies as st +from hypothesis.provisional import urls + +import fastkml.containers +import fastkml.features +import fastkml.kml +import fastkml.links +import fastkml.overlays +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 + + +class TestLxml(Lxml): + @given( + feature=st.one_of( + st.builds( + fastkml.containers.Document, + ), + st.builds( + fastkml.containers.Folder, + ), + st.builds( + fastkml.features.Placemark, + ), + st.builds( + fastkml.overlays.GroundOverlay, + ), + st.builds( + fastkml.overlays.PhotoOverlay, + ), + st.builds( + fastkml.features.NetworkLink, + link=st.builds( + fastkml.links.Link, + href=urls(), + ), + ), + ), + ) + def test_fuzz_document( + self, + feature: fastkml.features._Feature, + ) -> None: + kml = fastkml.kml.KML( + features=[feature], # type: ignore[list-item] + ) + + assert_repr_roundtrip(kml) + assert_str_roundtrip(kml) + assert_str_roundtrip_terse(kml) + assert_str_roundtrip_verbose(kml) diff --git a/tests/hypothesis/links_test.py b/tests/hypothesis/links_test.py index b965783a..198e1778 100644 --- a/tests/hypothesis/links_test.py +++ b/tests/hypothesis/links_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 Link and Icon.""" + import string import typing from functools import partial @@ -65,7 +66,6 @@ class TestLxml(Lxml): - @pytest.mark.parametrize("cls", [fastkml.Link, fastkml.Icon]) @common_link() def test_fuzz_link( diff --git a/tests/hypothesis/multi_geometry_test.py b/tests/hypothesis/multi_geometry_test.py index e20950f3..f32935c4 100644 --- a/tests/hypothesis/multi_geometry_test.py +++ b/tests/hypothesis/multi_geometry_test.py @@ -15,6 +15,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Property based tests of the Geometry classes.""" + from __future__ import annotations from functools import partial diff --git a/tests/hypothesis/overlay_test.py b/tests/hypothesis/overlay_test.py new file mode 100644 index 00000000..5d7762ef --- /dev/null +++ b/tests/hypothesis/overlay_test.py @@ -0,0 +1,244 @@ +# 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 +"""Test Link and Icon.""" + +import typing + +from hypothesis import given +from hypothesis import strategies as st +from pygeoif.hypothesis.strategies import epsg4326 +from pygeoif.hypothesis.strategies import points + +import fastkml +import fastkml.enums +import fastkml.geometry +import fastkml.overlays +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 + + +class TestLxml(Lxml): + @given( + left_fov=st.one_of(st.none(), st.floats(min_value=-180, max_value=180)).filter( + lambda x: x != 0, + ), + right_fov=st.one_of(st.none(), st.floats(min_value=-180, max_value=180)).filter( + lambda x: x != 0, + ), + bottom_fov=st.one_of(st.none(), st.floats(min_value=-90, max_value=90)).filter( + lambda x: x != 0, + ), + top_fov=st.one_of(st.none(), st.floats(min_value=-90, max_value=90)).filter( + lambda x: x != 0, + ), + near=st.one_of( + st.none(), + st.floats(allow_nan=False, allow_infinity=False), + ).filter(lambda x: x != 0), + ) + def test_fuzz_view_volume( + self, + left_fov: typing.Optional[float], + right_fov: typing.Optional[float], + bottom_fov: typing.Optional[float], + top_fov: typing.Optional[float], + near: typing.Optional[float], + ) -> None: + view_volume = fastkml.overlays.ViewVolume( + left_fov=left_fov, + right_fov=right_fov, + bottom_fov=bottom_fov, + top_fov=top_fov, + near=near, + ) + + assert_repr_roundtrip(view_volume) + assert_str_roundtrip(view_volume) + assert_str_roundtrip_terse(view_volume) + assert_str_roundtrip_verbose(view_volume) + + @given( + tile_size=st.one_of(st.none(), st.integers(min_value=0, max_value=2**31 - 1)), + max_width=st.one_of(st.none(), st.integers(min_value=0, max_value=2**31 - 1)), + max_height=st.one_of(st.none(), st.integers(min_value=0, max_value=2**31 - 1)), + grid_origin=st.one_of(st.none(), st.sampled_from(fastkml.enums.GridOrigin)), + ) + def test_fuzz_image_pyramid( + self, + tile_size: typing.Optional[int], + max_width: typing.Optional[int], + max_height: typing.Optional[int], + grid_origin: typing.Optional[fastkml.enums.GridOrigin], + ) -> None: + image_pyramid = fastkml.overlays.ImagePyramid( + tile_size=tile_size, + max_width=max_width, + max_height=max_height, + grid_origin=grid_origin, + ) + + assert_repr_roundtrip(image_pyramid) + assert_str_roundtrip(image_pyramid) + assert_str_roundtrip_terse(image_pyramid) + assert_str_roundtrip_verbose(image_pyramid) + + @given( + north=st.one_of( + st.none(), + st.floats(min_value=-180, max_value=180).filter(lambda x: x != 0), + ), + south=st.one_of( + st.none(), + st.floats(min_value=-180, max_value=180).filter(lambda x: x != 0), + ), + east=st.one_of( + st.none(), + st.floats(min_value=-180, max_value=180).filter(lambda x: x != 0), + ), + west=st.one_of( + st.none(), + st.floats(min_value=-180, max_value=180).filter(lambda x: x != 0), + ), + rotation=st.one_of( + st.none(), + st.floats(min_value=-180, max_value=180).filter(lambda x: x != 0), + ), + ) + def test_fuzz_lat_lon_box( + self, + north: typing.Optional[float], + south: typing.Optional[float], + east: typing.Optional[float], + west: typing.Optional[float], + rotation: typing.Optional[float], + ) -> None: + lat_lon_box = fastkml.overlays.LatLonBox( + north=north, + south=south, + east=east, + west=west, + rotation=rotation, + ) + + assert_repr_roundtrip(lat_lon_box) + assert_str_roundtrip(lat_lon_box) + assert_str_roundtrip_terse(lat_lon_box) + assert_str_roundtrip_verbose(lat_lon_box) + + @given( + rotation=st.floats(min_value=-180, max_value=180).filter(lambda x: x != 0), + view_volume=st.one_of( + st.none(), + st.builds( + fastkml.overlays.ViewVolume, + left_fov=st.floats(min_value=-180, max_value=180).filter( + lambda x: x != 0, + ), + right_fov=st.floats(min_value=-180, max_value=180).filter( + lambda x: x != 0, + ), + bottom_fov=st.floats(min_value=-90, max_value=90).filter( + lambda x: x != 0, + ), + top_fov=st.floats(min_value=-90, max_value=90).filter(lambda x: x != 0), + near=st.floats(allow_nan=False, allow_infinity=False).filter( + lambda x: x != 0, + ), + ), + ), + image_pyramid=st.one_of( + st.none(), + st.builds( + fastkml.overlays.ImagePyramid, + tile_size=st.integers(min_value=0, max_value=2**31 - 1), + max_width=st.integers(min_value=0, max_value=2**31 - 1), + max_height=st.integers(min_value=0, max_value=2**31 - 1), + grid_origin=st.sampled_from(fastkml.enums.GridOrigin), + ), + ), + point=st.one_of( + st.none(), + st.builds( + fastkml.geometry.Point, + geometry=points(srs=epsg4326), + ), + ), + shape=st.one_of(st.none(), st.sampled_from(fastkml.enums.Shape)), + ) + def test_fuzz_photo_overlay( + self, + rotation: typing.Optional[float], + view_volume: typing.Optional[fastkml.overlays.ViewVolume], + image_pyramid: typing.Optional[fastkml.overlays.ImagePyramid], + point: typing.Optional[fastkml.geometry.Point], + shape: typing.Optional[fastkml.enums.Shape], + ) -> None: + photo_overlay = fastkml.overlays.PhotoOverlay( + id="photo_overlay1", + name="photo_overlay", + rotation=rotation, + image_pyramid=image_pyramid, + view_volume=view_volume, + point=point, + shape=shape, + ) + + assert_repr_roundtrip(photo_overlay) + assert_str_roundtrip(photo_overlay) + assert_str_roundtrip_terse(photo_overlay) + assert_str_roundtrip_verbose(photo_overlay) + + @given( + 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)), + lat_lon_box=st.one_of( + st.none(), + st.builds( + fastkml.overlays.LatLonBox, + north=st.floats(min_value=-180, max_value=180).filter(lambda x: x != 0), + south=st.floats(min_value=-180, max_value=180).filter(lambda x: x != 0), + east=st.floats(min_value=-180, max_value=180).filter(lambda x: x != 0), + west=st.floats(min_value=-180, max_value=180).filter(lambda x: x != 0), + rotation=st.floats(min_value=-180, max_value=180).filter( + lambda x: x != 0, + ), + ), + ), + ) + def test_fuzz_ground_overlay( + self, + altitude: typing.Optional[float], + altitude_mode: typing.Optional[fastkml.enums.AltitudeMode], + lat_lon_box: typing.Optional[fastkml.overlays.LatLonBox], + ) -> None: + ground_overlay = fastkml.overlays.GroundOverlay( + id="ground_overlay1", + name="ground_overlay", + altitude=altitude, + altitude_mode=altitude_mode, + lat_lon_box=lat_lon_box, + ) + + assert_repr_roundtrip(ground_overlay) + assert_str_roundtrip(ground_overlay) + assert_str_roundtrip_terse(ground_overlay) + assert_str_roundtrip_verbose(ground_overlay) diff --git a/tests/hypothesis/strategies.py b/tests/hypothesis/strategies.py index 0bedf8b0..4390510f 100644 --- a/tests/hypothesis/strategies.py +++ b/tests/hypothesis/strategies.py @@ -28,6 +28,7 @@ from pygeoif.hypothesis import strategies as geo_st import fastkml.enums +import fastkml.gx import fastkml.links import fastkml.styles from fastkml.times import KmlDateTime @@ -164,6 +165,19 @@ ), ) +track_items = partial( + st.builds, + fastkml.gx.TrackItem, + angle=st.builds( + fastkml.gx.Angle, + heading=st.floats(allow_nan=False, allow_infinity=False), + roll=st.floats(allow_nan=False, allow_infinity=False), + tilt=st.floats(allow_nan=False, allow_infinity=False), + ), + coord=geo_st.points(srs=geo_st.epsg4326), + when=kml_datetimes(), +) + @st.composite def query_strings(draw: st.DrawFn) -> str: diff --git a/tests/hypothesis/times_test.py b/tests/hypothesis/times_test.py index b7b1ab71..d52cb685 100644 --- a/tests/hypothesis/times_test.py +++ b/tests/hypothesis/times_test.py @@ -19,6 +19,7 @@ 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 diff --git a/tests/kml_test.py b/tests/kml_test.py index e7189417..bf74b9f5 100644 --- a/tests/kml_test.py +++ b/tests/kml_test.py @@ -15,10 +15,12 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Test the kml class.""" + import io import pathlib import pygeoif as geo +import pytest from pygeoif.geometry import Polygon from fastkml import containers @@ -191,7 +193,34 @@ class TestLxml(Lxml, TestStdLibrary): class TestLxmlParseKML(Lxml, TestParseKML): """Test with Lxml.""" - def test_from_string_with_unbound_prefix(self) -> None: + 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( '' "" @@ -199,7 +228,7 @@ def test_from_string_with_unbound_prefix(self) -> None: "" " ", ) - k = kml.KML.parse(doc, ns="{http://www.opengis.net/kml/2.2}") + 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) diff --git a/tests/links_test.py b/tests/links_test.py index b7628f63..cb822d4e 100644 --- a/tests/links_test.py +++ b/tests/links_test.py @@ -15,6 +15,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Test the kml classes.""" + from fastkml import links from fastkml.enums import RefreshMode from fastkml.enums import ViewRefreshMode diff --git a/tests/registry_test.py b/tests/registry_test.py index 083b3fcc..234c851b 100644 --- a/tests/registry_test.py +++ b/tests/registry_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 registry module.""" + from enum import Enum from typing import Any from typing import Dict diff --git a/tests/styles_test.py b/tests/styles_test.py index 3dc43252..67c73be1 100644 --- a/tests/styles_test.py +++ b/tests/styles_test.py @@ -125,8 +125,10 @@ def test_icon_style_read(self) -> None: assert icons.color == "ff2200ff" assert icons.color_mode == ColorMode("random") assert icons.scale == 5.0 + assert icons.icon assert icons.icon.href == "http://example.com/icon.png" assert icons.heading == 20.0 + assert icons.hot_spot assert icons.hot_spot.x == 0.5 assert icons.hot_spot.y == 0.7 assert icons.hot_spot.xunits.value == "fraction" diff --git a/tests/times_test.py b/tests/times_test.py index 201ba02d..53306819 100644 --- a/tests/times_test.py +++ b/tests/times_test.py @@ -15,6 +15,7 @@ # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA """Test the times classes.""" + import datetime import pytest diff --git a/tests/utils_test.py b/tests/utils_test.py index b7bb79a6..f52dcd98 100644 --- a/tests/utils_test.py +++ b/tests/utils_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 utils module.""" + from typing import List from fastkml import Schema @@ -29,7 +30,6 @@ class TestFindAll(StdLibrary): """Test the find_all function.""" def test_find_all(self) -> None: - class A: def __init__(self, x: int) -> None: self.x = x diff --git a/tests/validator_test.py b/tests/validator_test.py index 13a5d99c..2d7ac834 100644 --- a/tests/validator_test.py +++ b/tests/validator_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 validator module.""" + from pathlib import Path from typing import Final @@ -29,7 +30,6 @@ class TestStdLibrary(StdLibrary): - def setup_method(self) -> None: """Invalidate the cache before each test.""" get_schema_parser.cache_clear() @@ -66,7 +66,6 @@ def test_validate_mutual_exclusive_element_and_path(self) -> None: class TestLxml(Lxml): - def setup_method(self) -> None: """Invalidate the cache before each test.""" get_schema_parser.cache_clear() diff --git a/tests/views_test.py b/tests/views_test.py index 68231f15..f09a1168 100644 --- a/tests/views_test.py +++ b/tests/views_test.py @@ -16,7 +16,6 @@ """Test the (Abstract)Views classes.""" - from fastkml import views from fastkml.enums import AltitudeMode from tests.base import Lxml @@ -79,7 +78,6 @@ def test_camera_read(self) -> None: assert camera.target_id == "target-cam-id" def test_create_look_at(self) -> None: - look_at = views.LookAt( id="look-at-id", target_id="target-look-at-id", @@ -145,7 +143,9 @@ def test_region_with_all_optional_parameters(self) -> None: ), ) + assert region assert region.id == "region1" + assert region.lat_lon_alt_box assert region.lat_lon_alt_box.north == 37.85 assert region.lat_lon_alt_box.south == 37.80 assert region.lat_lon_alt_box.east == -122.35 @@ -153,12 +153,11 @@ def test_region_with_all_optional_parameters(self) -> None: assert region.lat_lon_alt_box.min_altitude == 0 assert region.lat_lon_alt_box.max_altitude == 1000 assert region.lat_lon_alt_box.altitude_mode == AltitudeMode.clamp_to_ground + assert region.lod assert region.lod.min_lod_pixels == 256 assert region.lod.max_lod_pixels == 1024 assert region.lod.min_fade_extent == 0 assert region.lod.max_fade_extent == 512 - assert region - assert bool(region) def test_region_read(self) -> None: doc = ( @@ -177,6 +176,7 @@ def test_region_read(self) -> None: region = views.Region.from_string(doc) assert region.id == "region1" + assert region.lat_lon_alt_box assert region.lat_lon_alt_box.north == 37.85 assert region.lat_lon_alt_box.south == 37.80 assert region.lat_lon_alt_box.east == -122.35 @@ -184,6 +184,7 @@ def test_region_read(self) -> None: assert region.lat_lon_alt_box.min_altitude == 0 assert region.lat_lon_alt_box.max_altitude == 1000 assert region.lat_lon_alt_box.altitude_mode == AltitudeMode.clamp_to_ground + assert region.lod assert region.lod.min_lod_pixels == 256 assert region.lod.max_lod_pixels == 1024 assert region.lod.min_fade_extent == 0 diff --git a/tox.ini b/tox.ini index 76f60e60..4fb8dc1a 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ max_line_length = 89 ignore= W503 per-file-ignores = - tests/*.py: E722,E741,E501,DALL + tests/*.py: E722,E741,E501,DALL,ECE001,CCR001 examples/*.py: DALL fastkml/gx.py: LIT002 fastkml/views.py: LIT002