Skip to content

Commit

Permalink
Fix parsing errors and handle invalid values
Browse files Browse the repository at this point in the history
  • Loading branch information
cleder committed Jan 16, 2024
1 parent e234369 commit d66c8bb
Show file tree
Hide file tree
Showing 3 changed files with 135 additions and 31 deletions.
72 changes: 54 additions & 18 deletions fastkml/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,60 @@
https://developers.google.com/kml/documentation/kmlreference#kml-fields
"""
import logging
from enum import Enum
from enum import unique
from typing import Union

__all__ = ["AltitudeMode", "DateTimeResolution", "Verbosity"]

logger = logging.getLogger(__name__)

class REnum(Enum):
"""Enum with custom repr for eval roundtrip."""

def __repr__(self) -> str:
"""The string representation of the object."""
cls_name = self.__class__.__name__
return f"{cls_name}.{self.name}"
class RelaxedEnum(Enum):
"""
Enum with relaxed string value matching.
This class provides an enum with relaxed value matching, allowing case-insensitive
comparison of enum values. If a value is not found in the enum, it will attempt to
find a case-insensitive match. If no match is found, a `ValueError` is raised.
Usage:
To use this enum, simply subclass `RelaxedEnum` and define your enum values.
Example:
-------
class MyEnum(RelaxedEnum):
VALUE1 = "value1"
VALUE2 = "value2"
my_value = MyEnum("VALUE1") # Case-insensitive match
print(my_value) # Output: MyEnum.VALUE1
The subclass must define the values as strings.
"""

@classmethod
def _missing_(cls, value: object) -> "RelaxedEnum":
assert isinstance(value, str)
value = value.lower()
for member in cls:
assert isinstance(member.value, str)
if member.value.lower() == value.lower():
logger.warning(
f"{cls.__name__}: "
f"Found case-insensitive match for {value} in {member.value}",
)
return member
msg = (
f"Unknown value '{value}' for {cls.__name__}. "
f"Known values are {', '.join(member.value for member in cls)}."
)
raise ValueError(msg)


@unique
class Verbosity(REnum):
class Verbosity(Enum):
"""Enum to represent the different verbosity levels."""

quiet = 0
Expand All @@ -47,7 +83,7 @@ class Verbosity(REnum):


@unique
class DateTimeResolution(REnum):
class DateTimeResolution(RelaxedEnum):
"""Enum to represent the different date time resolutions."""

datetime = "dateTime"
Expand All @@ -57,7 +93,7 @@ class DateTimeResolution(REnum):


@unique
class AltitudeMode(REnum):
class AltitudeMode(RelaxedEnum):
"""
Enum to represent the different altitude modes.
Expand Down Expand Up @@ -105,7 +141,7 @@ class AltitudeMode(REnum):


@unique
class DataType(REnum):
class DataType(RelaxedEnum):
string = "string"
int_ = "int"
uint = "uint"
Expand All @@ -130,7 +166,7 @@ def convert(self, value: str) -> Union[str, int, float, bool]:


@unique
class RefreshMode(REnum):
class RefreshMode(RelaxedEnum):
"""
Enum to represent the different refresh modes.
Expand All @@ -143,7 +179,7 @@ class RefreshMode(REnum):


@unique
class ViewRefreshMode(REnum):
class ViewRefreshMode(RelaxedEnum):
"""
Enum to represent the different view refresh modes.
Expand All @@ -157,7 +193,7 @@ class ViewRefreshMode(REnum):


@unique
class ColorMode(REnum):
class ColorMode(RelaxedEnum):
"""
Enum to represent the different color modes.
Expand All @@ -169,7 +205,7 @@ class ColorMode(REnum):


@unique
class DisplayMode(REnum):
class DisplayMode(RelaxedEnum):
"""
DisplayMode for BalloonStyle.
Expand All @@ -185,7 +221,7 @@ class DisplayMode(REnum):


@unique
class Shape(REnum):
class Shape(RelaxedEnum):
"""
Shape for PhotoOverlay.
Expand All @@ -202,7 +238,7 @@ class Shape(REnum):


@unique
class GridOrigin(REnum):
class GridOrigin(RelaxedEnum):
"""
GridOrigin for GroundOverlay.
Expand All @@ -216,7 +252,7 @@ class GridOrigin(REnum):


@unique
class Units(REnum):
class Units(RelaxedEnum):
"""
Units for ScreenOverlay and Hotspot.
Expand All @@ -229,7 +265,7 @@ class Units(REnum):


@unique
class PairKey(REnum):
class PairKey(RelaxedEnum):
"""
Key for Pair.
"""
Expand Down
77 changes: 66 additions & 11 deletions fastkml/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,44 @@
from fastkml import config
from fastkml.base import _XMLObject
from fastkml.enums import Verbosity
from fastkml.exceptions import KMLParseError
from fastkml.registry import known_types
from fastkml.types import Element

logger = logging.getLogger(__name__)


def handle_error(
*,
error: Exception,
strict: bool,
element: Element,
node: Element,
expected: str,
) -> None:
"""Handle an error."""
serialized_element = config.etree.tostring( # type: ignore[attr-defined]
element,
encoding="UTF-8",
).decode(
"UTF-8",
)
serialized_node = config.etree.tostring( # type: ignore[attr-defined]
node,
encoding="UTF-8",
).decode(
"UTF-8",
)
msg = (
f"Error parsing '{serialized_node}' in '{serialized_element}'; "
f"expected: {expected}. \n {error}"
)
if strict:
raise KMLParseError(msg) from error
else:
logger.warning("%s, %s", error, msg)


def text_subelement(
obj: _XMLObject,
*,
Expand Down Expand Up @@ -195,8 +227,14 @@ def subelement_bool_kwarg(
return {kwarg: False}
try:
return {kwarg: bool(int(float(node.text.strip())))}
except ValueError:
return {}
except ValueError as exc:
handle_error(
error=exc,
strict=strict,
element=element,
node=node,
expected="Boolean",
)
return {}


Expand All @@ -216,10 +254,14 @@ def subelement_int_kwarg(
if node.text and node.text.strip():
try:
return {kwarg: int(node.text.strip())}
except ValueError:
if strict:
raise
return {}
except ValueError as exc:
handle_error(
error=exc,
strict=strict,
element=element,
node=node,
expected="Integer",
)
return {}


Expand All @@ -239,10 +281,14 @@ def subelement_float_kwarg(
if node.text and node.text.strip():
try:
return {kwarg: float(node.text.strip())}
except ValueError:
if strict:
raise
return {}
except ValueError as exc:
handle_error(
error=exc,
strict=strict,
element=element,
node=node,
expected="Float",
)
return {}


Expand All @@ -262,7 +308,16 @@ def subelement_enum_kwarg(
if node is None:
return {}
if node.text and node.text.strip():
return {kwarg: classes[0](node.text.strip())}
try:
return {kwarg: classes[0](node.text.strip())}
except ValueError as exc:
handle_error(
error=exc,
strict=strict,
element=element,
node=node,
expected="Enum",
)
return {}


Expand Down
17 changes: 15 additions & 2 deletions tests/geometries/geometry_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import pytest
from pygeoif import geometry as geo

from fastkml import exceptions
from fastkml.geometry import AltitudeMode
from fastkml.geometry import LinearRing
from fastkml.geometry import LineString
Expand Down Expand Up @@ -337,10 +338,10 @@ def test_from_string(self) -> None:
assert g.altitude_mode == AltitudeMode.relative_to_ground
assert g.tessellate is True

def test_from_string_invalid_altitude_mode(self) -> None:
def test_from_string_invalid_altitude_mode_strict(self) -> None:
"""Test the from_string method."""
with pytest.raises(
ValueError,
exceptions.KMLParseError,
):
_Geometry.class_from_string(
'<_Geometry id="my-id" targetId="target_id">'
Expand All @@ -349,6 +350,18 @@ def test_from_string_invalid_altitude_mode(self) -> None:
ns="",
)

def test_from_string_invalid_altitude_mode_relaxed(self) -> None:
"""Test the from_string method."""
geom = _Geometry.class_from_string(
'<_Geometry id="my-id" targetId="target_id">'
"<altitudeMode>invalid</altitudeMode>"
"</_Geometry>",
ns="",
strict=False,
)

assert geom.altitude_mode is None

def test_from_string_invalid_extrude(self) -> None:
"""Test the from_string method."""
with pytest.raises(
Expand Down

0 comments on commit d66c8bb

Please sign in to comment.